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.
@@ -0,0 +1,67 @@
1
+ module CableRoom
2
+ module Room
3
+ module MemberManagement
4
+ extend ActiveSupport::Concern
5
+
6
+ MEMBER_TIMEOUT = 30.seconds
7
+
8
+ included do
9
+ periodically :check_member_inactivity, every: MEMBER_TIMEOUT
10
+ end
11
+
12
+ protected
13
+
14
+ def on_member_left(mtok); end
15
+
16
+ def on_member_joined(mtok); end
17
+
18
+ def touch_member_activity(mtok)
19
+ member_data(mtok)[:last_seen_at] = Time.now
20
+ end
21
+
22
+ def member_data(mtok = @current_message_member_token)
23
+ @member_data ||= {}
24
+ @member_data[mtok] ||= HashWithIndifferentAccess.new
25
+ end
26
+
27
+ def handle_received_message(message)
28
+ mtok = message['mtok']
29
+
30
+ previous_mtok = @current_message_member_token
31
+ @current_message_member_token = mtok
32
+
33
+ case message['type']
34
+ when 'member_joined'
35
+ mdata = member_data(mtok)
36
+ mdata[:last_seen_at] = Time.now
37
+ touch_member_activity(mtok)
38
+
39
+ mdata.merge! ::ActiveJob::Arguments.deserialize(message['extra'])[0] if message['extra'].present?
40
+
41
+ on_member_joined(mtok)
42
+ when 'member_left'
43
+ on_member_left(mtok)
44
+ @member_data.delete(mtok)
45
+ when 'member_ping'
46
+ touch_member_activity(mtok)
47
+ else
48
+ super
49
+ end
50
+ ensure
51
+ @current_message_member_token = previous_mtok
52
+ end
53
+
54
+ def check_member_inactivity
55
+ return unless @member_data
56
+
57
+ threshold = MEMBER_TIMEOUT.ago
58
+ @member_data.each do |mtok, data|
59
+ next unless data[:last_seen_at] && data[:last_seen_at] < threshold
60
+
61
+ on_member_left(mtok)
62
+ @member_data.delete(mtok)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,84 @@
1
+ module CableRoom
2
+ module Room
3
+ module Reaping
4
+ extend ActiveSupport::Concern
5
+ include ActiveSupport::Callbacks
6
+
7
+ included do
8
+ # { key: nil, interval: 10.seconds, block: ->{} }
9
+ class_attribute :reaper_checkers, instance_writer: false, instance_predicate: false, default: []
10
+
11
+ after_startup do
12
+ cfgs = self.class.reaper_checkers
13
+
14
+ states = _reaper_states
15
+ cfgs.each do |cfg|
16
+ state = states[cfg] ||= {}
17
+ state[:timer] = start_periodic_timer(->{
18
+ if instance_exec(&cfg[:block])
19
+ shutdown!
20
+ else
21
+ ping_watchdog
22
+ end
23
+ }, every: cfg[:interval])
24
+ end
25
+ end
26
+
27
+ before_shutdown do
28
+ _reaper_states.each do |_, state|
29
+ state[:timer]&.shutdown
30
+ end
31
+ end
32
+ end
33
+
34
+ class_methods do
35
+ def reap_when(grace: 30.seconds, interval: 10.seconds, key: nil, &blk)
36
+ cfg = { interval: interval, key: key }
37
+
38
+ cfg[:block] = -> {
39
+ state = _reaper_states[cfg]
40
+ result = instance_exec(&blk)
41
+
42
+ next true if result == :reap
43
+
44
+ if result
45
+ next true unless grace && grace > 0
46
+ state[:last_keep_at] ||= Time.current
47
+ next true if (Time.current - state[:last_keep_at]) > grace
48
+ else
49
+ state[:last_keep_at] = Time.current
50
+ end
51
+
52
+ false
53
+ }
54
+
55
+ reaper_checkers << cfg
56
+ end
57
+ end
58
+
59
+ protected
60
+
61
+ def ping_watchdog
62
+ @cable_channel.ping_watchdog
63
+ end
64
+
65
+ def check_reapers_now!
66
+ reaper_config = self.class.reaper_checkers
67
+ return if reaper_config.empty?
68
+
69
+ if reaper_config.any? { |cfg| instance_exec(&cfg[:block]) }
70
+ shutdown!
71
+ return
72
+ end
73
+
74
+ ping_watchdog
75
+ end
76
+
77
+ private
78
+
79
+ def _reaper_states
80
+ @_reaper_states ||= {}
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,13 @@
1
+ module CableRoom
2
+ module Room
3
+ module Threading
4
+ extend ActiveSupport::Concern
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.)
8
+ def async(&blk)
9
+ @cable_channel.post_work(thread_safe: true, &blk)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,72 @@
1
+ module CableRoom
2
+ module Room
3
+ module UserManagement
4
+ extend ActiveSupport::Concern
5
+
6
+ def initialize(...)
7
+ @_user_state_map = {}
8
+ @_user_map_mutex = Monitor.new
9
+ super
10
+ end
11
+
12
+ def on_user_left(user); end
13
+
14
+ def on_member_left(mtok)
15
+ _user_state_transaction do |usm, u|
16
+ next unless usm.present?
17
+ usm[:member_tokens].delete(mtok)
18
+ if usm[:member_tokens].empty?
19
+ @_user_state_map.delete(u)
20
+ on_user_left(u)
21
+ end
22
+ end
23
+
24
+ super
25
+ end
26
+
27
+ def on_user_joined(user); end
28
+
29
+ def on_member_joined(mtok)
30
+ _user_state_transaction do |usm, u|
31
+ if usm.nil?
32
+ usm = @_user_state_map[u] = {
33
+ member_tokens: Set.new,
34
+ }
35
+ is_new = true
36
+ end
37
+ usm[:member_tokens] << mtok
38
+ on_user_joined(u) if is_new
39
+ end
40
+
41
+ super
42
+ end
43
+
44
+ def connected_users
45
+ _user_state_map.keys
46
+ end
47
+
48
+ def transmit_to_user(msg, user: nil)
49
+ raise "No user specified" if user.nil? && current_user.nil?
50
+ user ||= current_user
51
+
52
+ _user_state_map[user][:member_tokens].each do |mtok|
53
+ ports[mtok] << msg
54
+ end
55
+ end
56
+
57
+ def current_user
58
+ member_data[:as]
59
+ end
60
+
61
+ private
62
+
63
+ def _user_state_transaction(user = current_user)
64
+ return unless user.present?
65
+ @_user_map_mutex.synchronize do
66
+ usm = @_user_state_map[user]
67
+ yield usm, user
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,20 @@
1
+ module CableRoom
2
+ module Room
3
+ extend ActiveSupport::Autoload
4
+
5
+ eager_autoload do
6
+ autoload :Base
7
+
8
+ # autoload :Ports
9
+ autoload :Callbacks
10
+ autoload :Threading
11
+ autoload :ChannelAdapter
12
+ autoload :InputHandling
13
+ autoload :Lifecycle
14
+ autoload :Reaping
15
+
16
+ autoload :MemberManagement
17
+ autoload :UserManagement
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,170 @@
1
+ module CableRoom
2
+ module RoomMember
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ periodically :ping_room_memberships, every: 10.seconds
7
+
8
+ after_unsubscribe do
9
+ to_close = _room_memberships.to_a
10
+ _room_memberships.clear
11
+ to_close.each(&:leave!)
12
+ end
13
+ end
14
+
15
+ def _room_memberships
16
+ @_room_memberships ||= Set.new
17
+ end
18
+
19
+ protected
20
+
21
+ def join_room(room_class, room_key = nil, as: :not_given, forward: false, **kwargs, &blk)
22
+ if forward
23
+ # raise ArgumentError, "Cannot specify both `forward: true` and `on_message:`" if kwargs[:on_message]
24
+ original_on_message = kwargs[:on_message]
25
+ kwargs[:on_message] = ->(msg) {
26
+ original_on_message&.call(msg)
27
+ transmit(msg)
28
+ }
29
+ end
30
+
31
+ if blk
32
+ raise ArgumentError, "Cannot specify both a block and `on_opened:`" if kwargs[:on_opened]
33
+ kwargs[:on_opened] = blk
34
+ end
35
+
36
+ as = respond_to?(:current_user) ? current_user : nil if as == :not_given
37
+
38
+ if as
39
+ kwargs[:extra] ||= {}
40
+ kwargs[:extra][:as] = as
41
+ end
42
+
43
+ RoomMembership.new(self, room_class, room_key, **kwargs)
44
+ end
45
+
46
+ def ping_room_memberships
47
+ _room_memberships.each(&:ping!)
48
+ end
49
+ end
50
+
51
+ class RoomMembership
52
+ include Ports
53
+
54
+ attr_reader :room_class
55
+
56
+ def initialize(
57
+ cable_channel,
58
+ room_class,
59
+ room_key,
60
+ create: false,
61
+ on_closed: nil,
62
+ on_opened: nil,
63
+ on_message: nil,
64
+ extra: nil
65
+ )
66
+ @token = SecureRandom.hex(16)
67
+ @has_left = false
68
+
69
+ @cable_channel = cable_channel
70
+ @room_class = room_class
71
+ @room_key = room_key
72
+ @allow_create = create
73
+
74
+ @on_opened = on_opened
75
+ @on_closed = on_closed
76
+ @on_message = on_message
77
+
78
+ @extra = extra
79
+
80
+ @cable_channel._room_memberships << self
81
+
82
+ # Listen to public/broadcast channel
83
+ stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
84
+ handle_received_message(message)
85
+ end
86
+
87
+ # Listen to private channel
88
+ stream_port(@token) do |message|
89
+ handle_received_message(message)
90
+ end
91
+
92
+ transmit_member_joined
93
+
94
+ maybe_provision_room
95
+ end
96
+
97
+ delegate :logger, to: :@cable_channel
98
+
99
+ def <<(data)
100
+ port_transmit(room_class::ROOM_IN_CHANNEL, data)
101
+ end
102
+
103
+ def left?
104
+ @has_left
105
+ end
106
+
107
+ def ping!
108
+ return if left?
109
+
110
+ self << { type: 'member_ping' }
111
+ maybe_provision_room
112
+ end
113
+
114
+ def leave!
115
+ return if left?
116
+
117
+ close_streamed_ports!
118
+ @cable_channel._room_memberships.delete(self)
119
+ self << { type: 'member_left' }
120
+ @has_left = true
121
+ @on_closed&.call(self)
122
+ end
123
+
124
+ def key; @room_key; end
125
+
126
+ protected
127
+
128
+ def port_transmit(port, data)
129
+ data[:mtok] = @token
130
+ super
131
+ end
132
+
133
+ def room_port_key(port)
134
+ room_class.room_port_key(@room_key, port)
135
+ end
136
+
137
+ def handle_received_message(message)
138
+ return leave! if message == "KILL"
139
+
140
+ case message['type']
141
+ when 'room_opened'
142
+ transmit_member_joined
143
+ @on_opened&.call(self)
144
+ when 'room_closed'
145
+ @on_closed&.call(self)
146
+ maybe_provision_room
147
+ else
148
+ logger.warn "Unknown room message type: #{message['type'].inspect}" unless @on_message
149
+ end
150
+
151
+ @on_message&.call(message)
152
+ end
153
+
154
+ private
155
+
156
+ def transmit_member_joined
157
+ msg = { type: 'member_joined' }
158
+ msg[:extra] = ::ActiveJob::Arguments.serialize([@extra]) if @extra
159
+ self << msg
160
+ end
161
+
162
+ def maybe_provision_room
163
+ return if left?
164
+ return unless @allow_create
165
+ return if ChannelTracker.instance.shutdown?
166
+
167
+ @room_class.ensure(@room_key)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,3 @@
1
+ module CableRoom
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/cable_room.rb ADDED
@@ -0,0 +1,36 @@
1
+
2
+ require 'active_support/concern'
3
+ require 'active_support/time'
4
+ require 'active_support/core_ext/module'
5
+
6
+ require 'action_cable'
7
+ require 'redlock'
8
+ require 'rufus-scheduler'
9
+
10
+ require_relative 'cable_room/railtie'
11
+
12
+ require_relative 'cable_room/channel_base'
13
+ require_relative 'cable_room/channel_tracker'
14
+ require_relative 'cable_room/ports'
15
+ require_relative 'cable_room/room_member'
16
+ require_relative 'cable_room/room/'
17
+ require_relative 'cable_room/version'
18
+
19
+ module CableRoom
20
+ class << self
21
+ def redis_pool
22
+ require 'rediconn'
23
+ @redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
24
+ end
25
+
26
+ def redis(&blk)
27
+ redis_pool.lazy_with(&blk)
28
+ end
29
+
30
+ def lock_manager
31
+ @lock_manager ||= Redlock::Client.new([
32
+ CableRoom.redis,
33
+ ])
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,113 @@
1
+ require "spec_helper"
2
+
3
+ RSpec.describe CableRoom::RoomMember do
4
+ RUN_KEY = SecureRandom.hex(8)
5
+
6
+ class TestRoom < CableRoom::Room::Base
7
+ cattr_accessor :latest_instance
8
+
9
+ after_startup do
10
+ self.class.latest_instance = self
11
+ self << { type: "custom_started" }
12
+ end
13
+
14
+ before_shutdown do
15
+
16
+ end
17
+ end
18
+
19
+ class TestChannel < ApplicationCable::Channel
20
+ include CableRoom::RoomMember
21
+
22
+ attr_reader :room
23
+
24
+ def subscribed
25
+ key = RUN_KEY + (params[:key] || SecureRandom.hex(8))
26
+ @room = join_room(TestRoom, key, create: params[:create])
27
+ end
28
+
29
+ def unsubscribed
30
+ @room.leave!
31
+ end
32
+ end
33
+
34
+ describe TestChannel, type: :channel do
35
+ before do
36
+ stub_connection current_user: nil
37
+ end
38
+
39
+ it "provisions a room if it doesn't exist" do
40
+ expect {
41
+ subscribe create: true
42
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(1)
43
+ unsubscribe
44
+ end
45
+
46
+ it "opens the expected streams" do
47
+ key = 'ABC'
48
+ subscribe key:, create: true
49
+ expect(subscription).to be_confirmed
50
+ expect(subscription).to have_stream_from(/TestRoom:\w+ABC:from_room/)
51
+ expect(subscription).to have_stream_from(/TestRoom:\w+ABC:\w+/)
52
+ unsubscribe
53
+ end
54
+
55
+ it "does not provision a room if `create: false`" do
56
+ expect {
57
+ subscribe create: false
58
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(0)
59
+ end
60
+
61
+ it "does not provision a duplicate room" do
62
+ key = SecureRandom.hex(8)
63
+ expect {
64
+ subscribe key: key, create: true
65
+ subscribe key: key, create: true
66
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(1)
67
+ unsubscribe
68
+ end
69
+
70
+ it "can provision multiple rooms" do
71
+ expect {
72
+ subscribe key: 'A', create: true
73
+ subscribe key: 'B', create: true
74
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(2)
75
+ unsubscribe
76
+ end
77
+
78
+ it "does not shutdown immediately on unsubscribe" do
79
+ subscribe create: true
80
+ expect {
81
+ unsubscribe
82
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(0)
83
+ end
84
+
85
+ it "shuts down when the room receives a KILL message" do
86
+ subscribe create: true
87
+ expect {
88
+ room = subscription.room
89
+ TestRoom.send_message(room.key, "KILL", port: :to_room)
90
+ sleep 0.1
91
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(-1)
92
+ end
93
+
94
+ it "shuts down when the room is idle" do
95
+ subscribe create: true
96
+ unsubscribe
97
+
98
+ room = TestRoom.latest_instance
99
+ vchan = room.instance_variable_get(:@cable_channel)
100
+ expect {
101
+ room.send(:ping_watchdog)
102
+ vchan.send(:check_room_watchdog)
103
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(0)
104
+
105
+ vchan.instance_variable_set(:@last_watchdog_ping_at, 1.hour.ago)
106
+
107
+ expect {
108
+ vchan.send(:check_room_watchdog)
109
+ sleep 0.1
110
+ }.to change { CableRoom::ChannelTracker.instance.room_channels.count }.by(-1)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,2 @@
1
+ test:
2
+ adapter: test
@@ -0,0 +1,5 @@
1
+ test:
2
+ database: cable-room-test
3
+ adapter: postgresql
4
+ encoding: unicode
5
+ pool: 15
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ # Add your own routes here, or remove this file if you don't have need for it.
5
+ end
@@ -0,0 +1,3 @@
1
+ test:
2
+ service: Disk
3
+ root: /Users/eknapp/code/CableRoom/tmp/storage
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Schema.define do
4
+ # Set up any tables you need to exist for your test suite that don't belong
5
+ # in migrations.
6
+ end