cable_room 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bc7b6f7f0f2c70e5db315cd4dc11ff01e7b0d8dcd56ba5d54159d2c0f3b6d56e
4
+ data.tar.gz: b8a9866e23a63d93cd52c88ce9008d194f2ed3cea2060b0b533c5236235fe244
5
+ SHA512:
6
+ metadata.gz: c13aee8af8dba031b0e7333539bde585f8e898a8afbf87657a0fc0128e21c32580149b866107c976c4742df690f8c838d0e094b63e96d140a1d6fb6731a8d652
7
+ data.tar.gz: 2ca7afb01a2bf5b2c0096045f6f486ecf8a744b63017497fcf9dd835d770d1fd99b37d63a8ff8c442e1aacd7ed5e0167725f08077c93a6de3fde13a94805c4bd
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # RediConn
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ begin
6
+ require "cable_room/version"
7
+ version = CableRoom::VERSION
8
+ rescue LoadError
9
+ version = "0.0.0.docker"
10
+ end
11
+
12
+ Gem::Specification.new do |spec|
13
+ spec.name = "cable_room"
14
+ spec.version = version
15
+ spec.authors = ["Ethan Knapp"]
16
+ spec.email = ["eknapp@instructure.com"]
17
+
18
+ spec.summary = "Build live Rooms on top of ActionCable"
19
+ spec.homepage = "https://instructure.com"
20
+
21
+ spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
22
+ spec.test_files = Dir["spec/**/*"]
23
+ spec.require_paths = ['lib']
24
+
25
+ spec.add_dependency "rails", ">= 7.2", "< 9.0"
26
+ spec.add_dependency "rufus-scheduler", "~> 3.6"
27
+ spec.add_dependency "redlock", "~> 2.0"
28
+ spec.add_dependency "rediconn", "~> 0.1.0"
29
+
30
+ spec.add_development_dependency "redis"
31
+ spec.add_development_dependency 'rspec', '~> 3'
32
+ end
@@ -0,0 +1,194 @@
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
+ @room.send(:_shutdown)
71
+ terminate!
72
+ end
73
+ end
74
+
75
+ def ping_watchdog
76
+ return if state == :dead
77
+
78
+ logger.debug "Ping watchdog"
79
+ @last_watchdog_ping_at = Time.current
80
+ end
81
+
82
+ def check_room_watchdog
83
+ return if state == :dead || state == :shutting_down
84
+
85
+ relock = CableRoom.lock_manager.lock(@lock_info[:resource], @lock_duration.in_milliseconds, extend: @lock_info)
86
+ unless relock
87
+ logger.warn "Lost lock, shutting down"
88
+ unsubscribe_from_channel
89
+ return
90
+ end
91
+
92
+ unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
93
+ logger.warn "Watchdog timeout for room #{@room.class.name}[#{@room.key}], shutting down"
94
+ initiate_shutdown("Watchdog timeout")
95
+ return
96
+ end
97
+ end
98
+
99
+ def initiate_shutdown(reason)
100
+ @mutex.synchronize do
101
+ return if @current_state == :dead || @current_state == :shutting_down
102
+
103
+ # Stop streams immediately to prevent further messages from being added
104
+ stop_all_streams
105
+
106
+ # Append the final unsubscribe to the work queue so we can process remaining messages first
107
+ post_work(async: false) do
108
+ unsubscribe_from_channel
109
+ end
110
+
111
+ @current_state = :shutting_down
112
+ end
113
+ end
114
+
115
+ def terminate!
116
+ @mutex.synchronize do
117
+ stop_all_streams
118
+ @current_state = :dead
119
+ CableRoom.lock_manager.unlock(@lock_info) if @lock_info
120
+ server.untrack_room_channel self
121
+ end
122
+ end
123
+
124
+ def post_work(async: false, &blk)
125
+ if async
126
+ # Async stuff is mostly untracked - we just post it to the worker pool and forget about it
127
+ worker_pool.executor.post(&blk)
128
+ else
129
+ @mutex.synchronize do
130
+ raise "Attempt to post work to dead or shutting down room" if @current_state == :dead || @current_state == :shutting_down
131
+ @work_queue << blk
132
+ end
133
+ schedule_work
134
+ end
135
+ end
136
+
137
+ def beat
138
+ post_work(async: true) do
139
+ check_room_watchdog
140
+ end
141
+ end
142
+
143
+ def transmit(*args)
144
+ logger.info("Channel.transmit called, ignoring: #{args.inspect}")
145
+ end
146
+
147
+ protected
148
+
149
+ def start_periodic_timer(callback, every:)
150
+ raise "Attempt to start periodic timer on a dead room" if state == :dead || state == :shutting_down
151
+
152
+ connection.server.scheduler.schedule_every(every) do
153
+ post_work(async: false) do
154
+ instance_exec(&callback)
155
+ end
156
+ end
157
+ end
158
+
159
+ def schedule_work
160
+ @mutex.synchronize do
161
+ return if @processing_work
162
+
163
+ work = @work_queue.shift
164
+ return unless work
165
+
166
+ @processing_work = true
167
+
168
+ worker_pool.executor.post do
169
+ begin
170
+ work.call
171
+ ensure
172
+ @mutex.synchronize do
173
+ @processing_work = false
174
+ end
175
+ schedule_work
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ class DummyConnection
183
+ attr_reader :channel
184
+ delegate :server, :logger, :tenant, :transmit, :post_work, to: :channel
185
+ delegate :event_loop, :pubsub, :worker_pool, to: :server
186
+
187
+ attr_reader :identifiers
188
+
189
+ def initialize(channel)
190
+ @channel = channel
191
+ @identifiers = []
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,118 @@
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
+ worker_pool.async_invoke(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
+ protected
79
+
80
+ def each_room_channel(&blk)
81
+ @room_channels.dup.each(&blk)
82
+ end
83
+
84
+ private
85
+
86
+ def cable_server
87
+ ActionCable.server
88
+ end
89
+
90
+ class ThreadPool < ActionCable::Server::Worker
91
+ set_callback :work, :around do |_, blk|
92
+ pconn = ActionCable::Server::Worker.connection
93
+ ActionCable::Server::Worker.connection = connection
94
+ blk.call
95
+ ensure
96
+ ActionCable::Server::Worker.connection = pconn
97
+ end
98
+
99
+ def async_invoke(receiver, method, *args, connection: receiver, async: nil, &block)
100
+ # Instead of posting directly to the global pool, post to a dedicated queue for the room/"connection".
101
+ # This makes each rooms so that they can be processed by at-most-one thread at a time, while still
102
+ # allowing multiple rooms to be processed by the same thread-pool.
103
+
104
+ # TODO Implement more of a round-robin approach so that no room can starve out others?
105
+
106
+ # "connection" here really references the Channel, since "Connections" in this context don't really exist
107
+
108
+ connection.post_work(async:) do
109
+ invoke(receiver, method, *args, connection: connection, &block)
110
+ end
111
+ end
112
+ end
113
+
114
+ Rufus::Scheduler::Job.class_eval do
115
+ alias_method :shutdown, :unschedule
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,62 @@
1
+ module CableRoom
2
+ module Ports
3
+ extend ActiveSupport::Concern
4
+
5
+ def ports
6
+ @ports_proxy ||= PortsProxy.new(self)
7
+ end
8
+
9
+ def stream_port(port, auto_close: true, &blk)
10
+ @cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
11
+ _streamed_ports << port if auto_close
12
+ end
13
+
14
+ def close_streamed_ports!
15
+ _streamed_ports.each do |port|
16
+ @cable_channel.stop_stream_from(room_port_key(port))
17
+ end
18
+ _streamed_ports.clear
19
+ end
20
+
21
+ def port_transmit(port, data)
22
+ ActionCable.server.broadcast(room_port_key(port), data)
23
+ end
24
+
25
+ protected
26
+
27
+ def _room_channel_class
28
+ room_class.channel_class
29
+ end
30
+
31
+ def _streamed_ports
32
+ @_streamed_ports ||= Set.new
33
+ end
34
+
35
+ class PortsProxy
36
+ def initialize(ports_concern)
37
+ @ports_concern = ports_concern
38
+ end
39
+
40
+ def [](port)
41
+ PortProxy.new(@ports_concern, port)
42
+ end
43
+ end
44
+
45
+ class PortProxy
46
+ def initialize(ports_concern, port)
47
+ @ports_concern = ports_concern
48
+ @port = port
49
+ end
50
+
51
+ def transmit(data)
52
+ @ports_concern.port_transmit(@port, data)
53
+ end
54
+
55
+ alias_method :<<, :transmit
56
+
57
+ def stream(&blk)
58
+ @ports_concern.stream_port(@port, &blk)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_model/railtie"
5
+
6
+ module CableRoom
7
+ class Railtie < Rails::Railtie # :nodoc:
8
+ rake_tasks do
9
+ end
10
+
11
+ console do |app|
12
+ end
13
+
14
+ runner do
15
+ end
16
+
17
+ initializer "cable_room.shutdown" do
18
+ Rails.application.reloader.before_class_unload do
19
+ CableRoom::ChannelTracker.instance.shutdown_rooms!
20
+ end
21
+ end
22
+
23
+ initializer "cable_room.monkey_patch_redlock" do
24
+ require 'redlock'
25
+
26
+ module ::Redlock
27
+ class Client
28
+ private
29
+ class RedisInstance
30
+ private
31
+ def recover_from_script_flush
32
+ retry_on_noscript = true
33
+ begin
34
+ yield
35
+ rescue RedisClient::CommandError, Redis::CommandError => e # <= ADDED Redis::CommandError
36
+ # When somebody has flushed the Redis instance's script cache, we might
37
+ # want to reload our scripts. Only attempt this once, though, to avoid
38
+ # going into an infinite loop.
39
+ if retry_on_noscript && e.message.include?('NOSCRIPT')
40
+ load_scripts
41
+ retry_on_noscript = false
42
+ retry
43
+ else
44
+ raise
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,92 @@
1
+ module CableRoom
2
+ module Room
3
+ class Base
4
+ ROOM_OUT_CHANNEL = :from_room # Many-to-one channel for messages to the room
5
+ ROOM_IN_CHANNEL = :to_room # One-to-many channel for messages from the room
6
+
7
+ LOCK_DURATION = 15.seconds
8
+ WATCH_DOG_INTERVAL = 15.seconds
9
+
10
+ class << self
11
+ def ensure(key = nil)
12
+ lock_key = room_port_key(key)
13
+ lock_info = CableRoom.lock_manager.lock(lock_key, self::LOCK_DURATION.in_milliseconds)
14
+ return false unless lock_info
15
+
16
+ vchannel = channel_class.new(
17
+ lock_info,
18
+ self,
19
+ key,
20
+ {
21
+ watchdog_interval: self::WATCH_DOG_INTERVAL,
22
+ lock_duration: self::LOCK_DURATION,
23
+ }
24
+ )
25
+ vchannel.subscribe_to_channel
26
+
27
+ true
28
+ end
29
+
30
+ def room_port_key(room_key, port = nil)
31
+ full_key = [room_key]
32
+ full_key << port if port
33
+ self::Channel.broadcasting_for(full_key)
34
+ end
35
+
36
+ def send_message(room_key, data, port: ROOM_IN_CHANNEL)
37
+ ActionCable.server.broadcast(room_port_key(room_key, port), data)
38
+ end
39
+
40
+ protected
41
+
42
+ def periodically(method, every:, &blk)
43
+ blk ||= method
44
+ blk = -> { send(method) } unless blk.is_a?(Proc)
45
+ # TODO Looking at the docs, this creates a thread for each timer - that seems expensive
46
+ channel_class.periodically(every:) do
47
+ room.instance_exec(&blk)
48
+ end
49
+ end
50
+ end
51
+
52
+ include Ports
53
+ include Callbacks
54
+ include Threading
55
+ include ChannelAdapter
56
+ include InputHandling
57
+ include Lifecycle
58
+ include Reaping
59
+
60
+ include MemberManagement
61
+ include UserManagement
62
+
63
+ attr_reader :key
64
+
65
+ delegate :logger, :params, to: :@cable_channel
66
+
67
+ def initialize(vchannel, key = nil)
68
+ super()
69
+ @key = key
70
+ @cable_channel = vchannel
71
+ end
72
+
73
+ def <<(data)
74
+ port_transmit(ROOM_OUT_CHANNEL, data)
75
+ end
76
+
77
+ protected
78
+
79
+ def room_class
80
+ self.class
81
+ end
82
+
83
+ def room_port_key(sub_channel = nil)
84
+ self.class.room_port_key(key, sub_channel)
85
+ end
86
+
87
+ def start_periodic_timer(callback, every:)
88
+ @cable_channel.send(:start_periodic_timer, -> { room.instance_exec(&callback) }, every:)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,33 @@
1
+ module CableRoom
2
+ module Room
3
+ module Callbacks
4
+ extend ActiveSupport::Concern
5
+ include ActiveSupport::Callbacks
6
+
7
+ included do
8
+ define_callbacks :startup
9
+ define_callbacks :shutdown
10
+ end
11
+
12
+ class_methods do
13
+ def before_startup(*methods, &block)
14
+ set_callback(:startup, :before, *methods, &block)
15
+ end
16
+
17
+ def after_startup(*methods, &block)
18
+ set_callback(:startup, :after, *methods, &block)
19
+ end
20
+ alias_method :on_startup, :after_startup
21
+
22
+ def before_shutdown(*methods, &block)
23
+ set_callback(:shutdown, :before, *methods, &block)
24
+ end
25
+
26
+ def after_shutdown(*methods, &block)
27
+ set_callback(:shutdown, :after, *methods, &block)
28
+ end
29
+ alias_method :on_shutdown, :after_shutdown
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
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
+ subclass.const_set(:Channel, Class.new(channel_class))
11
+ super
12
+ end
13
+
14
+ def channel_class
15
+ self::Channel
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ module CableRoom
2
+ module Room
3
+ module InputHandling
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_startup do
8
+ ports[self.class::ROOM_IN_CHANNEL].stream do |message|
9
+ logger.debug "Received message: #{message.inspect}"
10
+ if message == "KILL"
11
+ shutdown!
12
+ else
13
+ handle_received_message(message)
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ protected
20
+
21
+ def handle_received_message(message)
22
+ sym = :"on_#{message['type'].underscore.to_s.downcase}"
23
+ if respond_to?(sym, true)
24
+ send(sym, message)
25
+ else
26
+ logger.warn "Unknown room message type: #{message['type'].inspect}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ module CableRoom
2
+ module Room
3
+ module Lifecycle
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ after_startup do
8
+ logger.info "Started"
9
+ self << { type: 'room_opened' }
10
+ end
11
+
12
+ before_shutdown do
13
+ self << { type: 'room_closed' }
14
+ logger.info "Shutting down"
15
+ end
16
+
17
+ after_shutdown do
18
+ logger.info "Shutdown complete"
19
+ end
20
+ end
21
+
22
+ protected
23
+
24
+ def lifecycle_state
25
+ @cable_channel.state
26
+ end
27
+
28
+ # Requests that the Room shut down gracefully, processing any pending messages
29
+ def shutdown!(reason = "Room requested shutdown")
30
+ @cable_channel.initiate_shutdown(reason)
31
+ end
32
+
33
+ # Stops the room synchronously, ignoring remaining messages
34
+ def stop!
35
+ @cable_channel.unsubscribe_from_channel
36
+ end
37
+
38
+ private
39
+
40
+ def _startup
41
+ run_callbacks :startup do
42
+ startup if respond_to?(:startup)
43
+ end
44
+ end
45
+
46
+ def _shutdown
47
+ run_callbacks :shutdown do
48
+ shutdown if respond_to?(:shutdown)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end