cable_room 0.5.6.beta1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a40d9bc8bdd5485d15865fb687e644cc8ab0200548dada92f8e721f7ff16e4a
4
- data.tar.gz: 635ed5e9748b870a80d2d2928e253eb19d22a78f3e0ea652cd8c96e06b9ecc9e
3
+ metadata.gz: e13e24ffec8569b5db51f53c822502dedcce3f96425fcdf4a538d35aafd9299e
4
+ data.tar.gz: 67ba91a549a4534691f6d293833b87b1bac932e35d2861d6413b7dd9de594f2d
5
5
  SHA512:
6
- metadata.gz: 337ad6e6b5e715c6aec178b2fa0457b0fbc11ff95097434ddbe00e0ede2b9ab4dd06a166c2715ab8004cc79fc14fb7a16b6e832a5a9ed31eaf2e371144d18697
7
- data.tar.gz: 162b71c4fb01cb900be4d9e4edf8a7d7cbbb0f35ab1b4cb5dc52f1552a980e239cbd5f1d96e627409f53061e5829baebf0357b284fa5c2608732e853e02d17b7
6
+ metadata.gz: 65340bae098563857d504f2353776f8a294b39d6b15324307134e9a67339e61ec9db24f91bc022419c6d6f5eb80884ed7313f11ef66ee1db62aa919d09324275
7
+ data.tar.gz: d598d42249d1e22de5f11b2201b6b9dfa7951ea3d9086397ab25b77d7b6f2d368038e751eb1e78da3ffad8af22befa885e6bd3ecf08498568227a9131da11776
data/cable_room.gemspec CHANGED
@@ -19,13 +19,12 @@ Gem::Specification.new do |spec|
19
19
  spec.homepage = "https://instructure.com"
20
20
 
21
21
  spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
22
- spec.test_files = Dir["spec/**/*"]
23
22
  spec.require_paths = ['lib']
24
23
 
25
24
  spec.add_dependency "rails", ">= 7.2", "< 9.0"
26
25
  spec.add_dependency "rufus-scheduler", "~> 3.6"
27
26
  spec.add_dependency "redlock", "~> 2.0"
28
- spec.add_dependency "rediconn", "~> 0.1.0"
27
+ spec.add_dependency "rediconn", "~> 0.1.2"
29
28
 
30
29
  spec.add_development_dependency "redis"
31
30
  spec.add_development_dependency 'rspec', '~> 3'
@@ -148,10 +148,25 @@ module CableRoom
148
148
  _post_wrapped_work(**kwargs) do
149
149
  worker_pool.invoke(self, :instance_exec, connection: self, &blk)
150
150
  rescue => e
151
- logger.error "Error during work execution: #{e.class.name}: #{e.message}"
151
+ report_work_error(e)
152
152
  end
153
153
  end
154
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
+
155
170
  def beat
156
171
  post_work(async: true) do
157
172
  check_room_watchdog
@@ -167,11 +182,13 @@ module CableRoom
167
182
  def start_periodic_timer(callback, every:)
168
183
  raise "Attempt to start periodic timer on a dead room" if state == :dead || state == :shutting_down
169
184
 
170
- connection.server.scheduler.schedule_every(every) do
185
+ job = connection.server.scheduler.schedule_every(every) do
171
186
  post_work(async: false, silent: true) do
172
187
  instance_exec(&callback)
173
188
  end
174
189
  end
190
+
191
+ PeriodicTimer.new(job)
175
192
  end
176
193
 
177
194
  def schedule_work
@@ -197,9 +214,27 @@ module CableRoom
197
214
  end
198
215
  end
199
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
+
200
234
  class DummyConnection
201
235
  attr_reader :channel
202
- delegate :server, :logger, :tenant, :transmit, :post_work, :_post_wrapped_work, to: :channel
236
+ delegate :server, :logger, :tenant, :transmit, :post_work, :_post_wrapped_work,
237
+ :report_work_error, to: :channel
203
238
  delegate :event_loop, :pubsub, :worker_pool, to: :server
204
239
 
205
240
  attr_reader :identifiers
@@ -94,6 +94,23 @@ module CableRoom
94
94
  ActionCable::Server::Worker.connection = pconn
95
95
  end
96
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
+
97
114
  def async_invoke(receiver, method, *args, connection: receiver, &block)
98
115
  # Instead of posting directly to the global pool, post to a dedicated queue for the room/"connection".
99
116
  # This makes each rooms so that they can be processed by at-most-one thread at a time, while still
@@ -109,8 +126,5 @@ module CableRoom
109
126
  end
110
127
  end
111
128
 
112
- Rufus::Scheduler::Job.class_eval do
113
- alias_method :shutdown, :unschedule
114
- end
115
129
  end
116
130
  end
@@ -1,8 +1,8 @@
1
1
  module CableRoom
2
2
  module Room
3
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
4
+ ROOM_OUT_CHANNEL = :from_room # One-to-many: messages the Room broadcasts out to its members
5
+ ROOM_IN_CHANNEL = :to_room # Many-to-one: messages members send in to the Room
6
6
 
7
7
  LOCK_DURATION = 15.seconds
8
8
  WATCH_DOG_INTERVAL = 15.seconds
@@ -7,13 +7,11 @@ module CableRoom
7
7
 
8
8
  class_methods do
9
9
  def inherited(subclass)
10
- subclass.const_set(:Channel, Class.new(Channel))
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))
11
13
  super
12
14
  end
13
-
14
- def open_rooms
15
- ChannelTracker.room_channels.map(&:room)
16
- end
17
15
  end
18
16
  end
19
17
  end
@@ -8,13 +8,26 @@ module CableRoom
8
8
 
9
9
  class_methods do
10
10
  def authorize_inbound(symbol_or_proc = nil, only: nil, except: nil, &blk)
11
- raise ArgumentError, "Must provide either a symbol, proc, or block" unless symbol_or_proc ^ blk
11
+ unless symbol_or_proc.nil? ^ blk.nil?
12
+ raise ArgumentError, "Must provide exactly one of a symbol, proc, or block"
13
+ end
12
14
 
13
15
  only = Set.new(Array(only).map(&:to_sym)) if only
14
16
  except = Set.new(Array(except).map(&:to_sym)) if except
15
17
 
16
- blk = symbol_or_proc if symbol_or_proc.is_a?(Proc)
17
- blk = -> { send(symbol_or_proc) } if symbol_or_proc.is_a?(Symbol)
18
+ case symbol_or_proc
19
+ when nil then nil
20
+ when Proc then blk = symbol_or_proc
21
+ when Symbol
22
+ # Guard methods may take the message or read it off `message`
23
+ guard = symbol_or_proc
24
+ blk = proc do |msg|
25
+ m = method(guard)
26
+ m.arity == 0 ? m.call : m.call(msg)
27
+ end
28
+ else
29
+ raise ArgumentError, "Expected a symbol or proc, got #{symbol_or_proc.class}"
30
+ end
18
31
 
19
32
  set_callback(:receive_message, :before) do
20
33
  type = message['type'].to_s.underscore.to_sym
@@ -15,7 +15,8 @@ module CableRoom
15
15
  end
16
16
 
17
17
  def inherited(subclass)
18
- subclass.const_set(:PortClient, Class.new(PortClient))
18
+ # Descend from *this* class's PortClient, so customizations survive multiple levels of subclassing
19
+ subclass.const_set(:PortClient, Class.new(self::PortClient))
19
20
  super
20
21
  end
21
22
  end
@@ -28,22 +29,14 @@ module CableRoom
28
29
  on_port_connected { reply({type: 'port_acknowledged' }) }
29
30
 
30
31
  set_callback(:receive_message, :around) do |_, blk|
31
- previous_mtok = @current_message_origin
32
- begin
33
- mtok = message['mtok']
34
- @current_message_origin = mtok
35
- blk.call
36
- ensure
37
- @current_message_origin = previous_mtok
38
- end
32
+ with_message_origin(message['mtok']) { blk.call }
39
33
  end
40
34
 
41
35
  system_message_types(:port_connected, :port_disconnected, :port_ping)
42
36
 
43
- require_relative 'port_policies'
44
37
  include PortPolicies
45
38
 
46
- PortPolicies::TagPolicy.define_tag_alias :connect, [:port_connected, :port_ping, :port_disconnected]
39
+ define_tag_alias :connect, [:port_connected, :port_ping, :port_disconnected]
47
40
 
48
41
  inbound_tag_policy(priority: -10) do
49
42
  allow :*, :connect
@@ -58,7 +51,7 @@ module CableRoom
58
51
  def _apply_port_scope(client_port: nil, tag: nil, **kwargs)
59
52
  if client_port && tag
60
53
  client_port = resolve_client_port(client_port)
61
- throw :abort unless client_port && client_port[:tags]&.include?(tag) || client_port[:as] == tag
54
+ throw :abort unless client_port && (client_port[:tags]&.include?(tag) || client_port[:as] == tag)
62
55
  end
63
56
 
64
57
  { client_port: client_port || tag, **kwargs }
@@ -92,6 +85,16 @@ module CableRoom
92
85
  @_port_clients[@current_message_origin]
93
86
  end
94
87
 
88
+ # Run the block with `message_origin` (and anything derived from it) resolving to the given port token.
89
+ # Used both while handling an inbound message and when the room itself acts on a port's behalf.
90
+ def with_message_origin(token)
91
+ previous = @current_message_origin
92
+ @current_message_origin = token
93
+ yield
94
+ ensure
95
+ @current_message_origin = previous
96
+ end
97
+
95
98
  def connected_clients
96
99
  @_port_clients.values
97
100
  end
@@ -110,7 +113,8 @@ module CableRoom
110
113
  mo.tag!(message['tags'])
111
114
 
112
115
  if policy_allows?(message['type'].to_sym)
113
- mo.merge!(::ActiveJob::Arguments.deserialize(message['extra'])[0])
116
+ # `extra` is omitted entirely when the member joins without a user or extra metadata
117
+ mo.merge!(::ActiveJob::Arguments.deserialize(message['extra'])[0]) if message['extra']
114
118
 
115
119
  ActiveSupport::Notifications.instrument("port_connected.cable_room", { room: self, message: message }) do
116
120
  run_callbacks :port_connected do
@@ -139,13 +143,16 @@ module CableRoom
139
143
  def check_port_inactivity
140
144
  return unless @_port_clients
141
145
 
142
- threshold = PORT_TIMEOUT.ago
143
- @_port_clients.each do |mtok, data|
146
+ @_port_clients.to_a.each do |mtok, data|
144
147
  next if data.recently_seen?
145
148
 
146
- ActiveSupport::Notifications.instrument("port_disconnected.cable_room", { room: self, reason: :timeout }) do
147
- run_callbacks :port_disconnected do
148
- on_port_disconnected
149
+ # Callbacks run before the client is dropped, and with the origin set, so that handlers
150
+ # (including user cleanup) can tell *which* port went away.
151
+ with_message_origin(mtok) do
152
+ ActiveSupport::Notifications.instrument("port_disconnected.cable_room", { room: self, reason: :timeout }) do
153
+ run_callbacks :port_disconnected do
154
+ on_port_disconnected
155
+ end
149
156
  end
150
157
  end
151
158
  @_port_clients.delete(mtok)
@@ -11,25 +11,43 @@ module CableRoom
11
11
  end
12
12
  end
13
13
 
14
+ # Declare that `key` covers the given methods, for this Room class and its subclasses.
15
+ # A rule written against `key` then applies to every method it implies.
16
+ def define_tag_alias(key, implies)
17
+ merged = _port_tag_aliases.each_with_object({}) { |(method, keys), acc| acc[method] = keys.dup }
18
+
19
+ Array(implies).each do |method|
20
+ (merged[method.to_sym] ||= Set.new) << key.to_sym
21
+ end
22
+
23
+ self._port_tag_aliases = merged
24
+ @_tag_policy = nil
25
+ merged
26
+ end
27
+
14
28
  def inbound_tag_policy(**kwargs, &blk)
15
29
  if blk
16
- self._port_policy_blocks = [*(_port_policy_blocks || []), {
30
+ self._port_policy_blocks = [*_port_policy_blocks, {
17
31
  **kwargs,
18
32
  block: blk,
19
33
  }]
34
+ @_tag_policy = nil
20
35
  else
21
- @_tag_policy ||= TagPolicy.new().tap do |pol|
22
- (_port_policy_blocks || []).each do |p|
36
+ @_tag_policy ||= TagPolicy.new(_port_tag_aliases).tap do |pol|
37
+ _port_policy_blocks.each do |p|
23
38
  pol.evaluate(**p.except(:block), &p[:block])
24
39
  end
25
40
  end
26
- @_tag_policy
27
41
  end
28
42
  end
29
43
  end
30
44
 
31
45
  included do
32
- class_attribute :_port_policy_blocks, instance_writer: false, default: {}
46
+ class_attribute :_port_policy_blocks, instance_writer: false, default: []
47
+
48
+ # Aliases are per Room class (inherited by subclasses), so one Room's vocabulary
49
+ # cannot change how another Room's policies are interpreted.
50
+ class_attribute :_port_tag_aliases, instance_writer: false, default: {}
33
51
 
34
52
  authorize_inbound do |message|
35
53
  msg_type = message['type'].to_s.underscore.to_sym
@@ -45,16 +63,10 @@ module CableRoom
45
63
  end
46
64
 
47
65
  class TagPolicy
48
- ALIASES = {}
49
-
50
- def self.define_tag_alias(key, implies)
51
- Array(implies).each do |i|
52
- ALIASES[i] ||= Set.new
53
- ALIASES[i] << key
54
- end
55
- end
56
-
57
- def initialize()
66
+ # `aliases` maps a method to the set of alias keys that cover it, and is owned by the
67
+ # Room class this policy was built for
68
+ def initialize(aliases = {})
69
+ @aliases = aliases
58
70
  @policy_rules = []
59
71
  @fallback_rule = { action: :allow, priority: -100, tag: :*, methods: :* }
60
72
  append_rule(@fallback_rule)
@@ -121,8 +133,8 @@ module CableRoom
121
133
  all_aliases << method
122
134
 
123
135
  bfs(method) do |m|
124
- all_aliases.merge(ALIASES[m] || [])
125
- ALIASES[m]
136
+ all_aliases.merge(@aliases[m] || [])
137
+ @aliases[m]
126
138
  end
127
139
 
128
140
  all_aliases << :*
@@ -3,10 +3,46 @@ module CableRoom
3
3
  module Threading
4
4
  extend ActiveSupport::Concern
5
5
 
6
- # Run the given block in the background, so as not to block other operations w/i the Room
7
- # (By default, rooms are single-threaded, so any work will block all other work from occurring.)
6
+ # Run the given block off the Room's thread, so slow work doesn't stop the Room from
7
+ # processing anything else.
8
+ #
9
+ # A Room is otherwise single-threaded: messages and timers run one at a time, which is what
10
+ # makes Room state safe to touch without locks. Work posted here deliberately escapes that
11
+ # queue and runs *concurrently* with the Room, so it must not reference Room state. Capture
12
+ # everything the block needs before posting it:
13
+ #
14
+ # token = message_origin.token
15
+ # async { expensive_lookup(token) } # good - `token` was captured
16
+ #
17
+ # async { expensive_lookup(message_origin) } # BAD - races the Room's thread
18
+ #
19
+ # In particular `message` is nil inside the block (it is thread-local to the Room's thread),
20
+ # and `message_origin` and `reply` refer to whatever the Room is handling *now* rather than
21
+ # what it was handling when `async` was called.
22
+ #
23
+ # `self` is still the Room, so instance methods resolve normally - which is exactly why the
24
+ # state rule matters. To act on the result, hand it back to the Room's thread:
25
+ #
26
+ # token = message_origin.token
27
+ # async do
28
+ # result = expensive_lookup(token)
29
+ # on_room_thread { broadcast({ type: 'result', result: result }, client_port: token) }
30
+ # end
31
+ #
32
+ # Exceptions are reported through CableRoom.error_handler, the same as any other Room work.
33
+ #
34
+ # Note that this borrows a thread from the worker pool shared by every Room in the process,
35
+ # so blocking a Room's thread waiting on async work can starve other Rooms. Prefer handing
36
+ # results back with `on_room_thread` over waiting for them.
8
37
  def async(&blk)
9
- @cable_channel.post_work(thread_safe: true, &blk)
38
+ room = self
39
+ @cable_channel.post_work(async: true) { room.instance_exec(&blk) }
40
+ end
41
+
42
+ # Queue the block back onto the Room's own thread, where touching Room state is safe again.
43
+ def on_room_thread(&blk)
44
+ room = self
45
+ @cable_channel.post_work(async: false, silent: true) { room.instance_exec(&blk) }
10
46
  end
11
47
  end
12
48
  end
@@ -27,7 +27,7 @@ module CableRoom
27
27
 
28
28
  system_message_types(:user_joined, :user_left)
29
29
 
30
- PortPolicies::TagPolicy.define_tag_alias :join, [:connect, :user_joined, :user_left]
30
+ define_tag_alias :join, [:connect, :user_joined, :user_left]
31
31
 
32
32
  inbound_tag_policy(priority: -10) do
33
33
  allow :*, :join
@@ -15,6 +15,7 @@ module CableRoom
15
15
 
16
16
  autoload :PortScoping
17
17
  autoload :PortManagement
18
+ autoload :PortPolicies
18
19
  autoload :UserManagement
19
20
  autoload :Broadcasting
20
21
  end
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.5.6.beta1".freeze
2
+ VERSION = "0.6.0".freeze
3
3
  end
data/lib/cable_room.rb CHANGED
@@ -19,6 +19,23 @@ require_relative 'cable_room/version'
19
19
 
20
20
  module CableRoom
21
21
  class << self
22
+ # Called as `handler.call(error, context)` whenever work inside a Room raises. Rooms swallow
23
+ # exceptions so that one bad message can't kill the Room, which makes this the only way to
24
+ # find out that it happened. Set it to forward to your error reporter:
25
+ #
26
+ # CableRoom.error_handler = ->(error, context) { Sentry.capture_exception(error, extra: context) }
27
+ #
28
+ # An "error.cable_room" ActiveSupport notification is emitted regardless.
29
+ attr_accessor :error_handler
30
+
31
+ def report_error(error, **context)
32
+ ActiveSupport::Notifications.instrument("error.cable_room", { error: error, **context })
33
+ error_handler&.call(error, context)
34
+ rescue => handler_error
35
+ # A broken reporter must never mask the failure it was reporting
36
+ warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
37
+ end
38
+
22
39
  def redis_pool
23
40
  require 'rediconn'
24
41
  @redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cable_room
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.6.beta1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
@@ -63,14 +63,14 @@ dependencies:
63
63
  requirements:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
- version: 0.1.0
66
+ version: 0.1.2
67
67
  type: :runtime
68
68
  prerelease: false
69
69
  version_requirements: !ruby/object:Gem::Requirement
70
70
  requirements:
71
71
  - - "~>"
72
72
  - !ruby/object:Gem::Version
73
- version: 0.1.0
73
+ version: 0.1.2
74
74
  - !ruby/object:Gem::Dependency
75
75
  name: redis
76
76
  requirement: !ruby/object:Gem::Requirement
@@ -128,16 +128,6 @@ files:
128
128
  - lib/cable_room/room_member.rb
129
129
  - lib/cable_room/room_proxy_channel.rb
130
130
  - lib/cable_room/version.rb
131
- - spec/cable_room/e2e_room_spec.rb
132
- - spec/cable_room/room_member_spec.rb
133
- - spec/internal/config/cable.yml
134
- - spec/internal/config/database.yml
135
- - spec/internal/config/routes.rb
136
- - spec/internal/config/storage.yml
137
- - spec/internal/db/schema.rb
138
- - spec/internal/log/test.log
139
- - spec/internal/public/favicon.ico
140
- - spec/spec_helper.rb
141
131
  homepage: https://instructure.com
142
132
  licenses: []
143
133
  metadata: {}
@@ -158,14 +148,4 @@ requirements: []
158
148
  rubygems_version: 3.6.9
159
149
  specification_version: 4
160
150
  summary: Build live Rooms on top of ActionCable
161
- test_files:
162
- - spec/cable_room/e2e_room_spec.rb
163
- - spec/cable_room/room_member_spec.rb
164
- - spec/internal/config/cable.yml
165
- - spec/internal/config/database.yml
166
- - spec/internal/config/routes.rb
167
- - spec/internal/config/storage.yml
168
- - spec/internal/db/schema.rb
169
- - spec/internal/log/test.log
170
- - spec/internal/public/favicon.ico
171
- - spec/spec_helper.rb
151
+ test_files: []