cable_room 0.6.2 → 0.7.0.beta1
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 +4 -4
- data/CHANGELOG.md +122 -0
- data/README.md +1190 -0
- data/cable_room.gemspec +5 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/broadcaster.rb +116 -0
- data/lib/cable_room/bus.rb +372 -0
- data/lib/cable_room/cli.rb +237 -0
- data/lib/cable_room/config.rb +112 -0
- data/lib/cable_room/host/bus_inbound.rb +36 -0
- data/lib/cable_room/host/runner.rb +571 -0
- data/lib/cable_room/host/supervisor.rb +275 -0
- data/lib/cable_room/host/worker_pool.rb +37 -0
- data/lib/cable_room/host.rb +477 -0
- data/lib/cable_room/membership_store.rb +105 -0
- data/lib/cable_room/migration.rb +586 -0
- data/lib/cable_room/periodic_timer.rb +18 -0
- data/lib/cable_room/placement.rb +258 -0
- data/lib/cable_room/ports.rb +19 -11
- data/lib/cable_room/railtie.rb +3 -12
- data/lib/cable_room/room/base.rb +45 -39
- data/lib/cable_room/room/host_adapter.rb +52 -0
- data/lib/cable_room/room/lifecycle.rb +26 -9
- data/lib/cable_room/room/port_management.rb +57 -0
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +78 -0
- data/lib/cable_room/room/threading.rb +2 -2
- data/lib/cable_room/room/user_management.rb +27 -0
- data/lib/cable_room/room.rb +5 -2
- data/lib/cable_room/room_member.rb +260 -84
- data/lib/cable_room/room_proxy_channel.rb +13 -2
- data/lib/cable_room/snapshot.rb +136 -0
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +57 -2
- metadata +25 -9
- data/lib/cable_room/channel_base.rb +0 -262
- data/lib/cable_room/channel_tracker.rb +0 -130
- data/lib/cable_room/room/channel_adapter.rb +0 -18
|
@@ -13,12 +13,17 @@ module CableRoom
|
|
|
13
13
|
@room_membership = subscribe_to_room
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The channel's actions are `hello` and `ping` (inherited from RoomMember: the client performs
|
|
17
|
+
# hello once its subscription is confirmed, and the membership announces itself to the room)
|
|
18
|
+
# and `receive`, which pipes everything else the client sends into the room. Until the room
|
|
19
|
+
# has acknowledged the membership (or, under AnyCable, until hello has gone out — see
|
|
20
|
+
# RoomMembership#accepts_input?) there is nowhere for input to go, so it is dropped.
|
|
16
21
|
def receive(data)
|
|
17
|
-
|
|
22
|
+
room_membership << data if room_membership&.accepts_input?
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def unsubscribed
|
|
21
|
-
|
|
26
|
+
room_membership&.leave!
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
protected
|
|
@@ -27,6 +32,12 @@ module CableRoom
|
|
|
27
32
|
raise NotImplementedError
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
# The membership `subscribed` created. Under AnyCable this channel instance may not be the one
|
|
36
|
+
# that ran `subscribed`, so fall back to the memberships rebuilt from the channel state.
|
|
37
|
+
def room_membership
|
|
38
|
+
@room_membership ||= room_memberships.first
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
def join_room(*args, **kwargs, &blk)
|
|
31
42
|
kwargs[:forward] = true
|
|
32
43
|
super
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module CableRoom
|
|
2
|
+
# The gem-owned picture of a running room, as one JSON document, so a room can be rebuilt on
|
|
3
|
+
# another host with its members none the wiser. `take` produces it from a frozen room (see
|
|
4
|
+
# Host::Runner#freeze!); `Host#restore_room` consumes it. Version 1 looks like this (string
|
|
5
|
+
# keys, because it has been through JSON):
|
|
6
|
+
#
|
|
7
|
+
# {
|
|
8
|
+
# "version" => 1,
|
|
9
|
+
# "room_class" => "QuizRoom",
|
|
10
|
+
# "key" => <room key>, # ActiveJob-serialized, so a record key travels as a GlobalID
|
|
11
|
+
# "port_clients" => [
|
|
12
|
+
# {
|
|
13
|
+
# "token" => "3f9a...",
|
|
14
|
+
# "tags" => ["admin"],
|
|
15
|
+
# "as" => <user or nil>, # ActiveJob-serialized (GlobalID for records)
|
|
16
|
+
# "last_seen_at" => "2026-08-27T18:02:11.123456Z",
|
|
17
|
+
# "metadata" => { ... } # everything else on the PortClient: what `extra:`
|
|
18
|
+
# } # merged in, plus anything the room set on it
|
|
19
|
+
# ],
|
|
20
|
+
# "user_state" => [{ "user" => <user>, "port_tokens" => ["3f9a..."] }],
|
|
21
|
+
# "reaper_state" => [{ "key" => "idle", "index" => 0, "last_keep_at" => "<ISO8601>" | nil }],
|
|
22
|
+
# "app_state" => <whatever the Room's snapshot_state returned, or nil>
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# `reaper_state` carries wall-clock times, not remaining durations, so a room restored on
|
|
26
|
+
# another host keeps the same deadline it had. `app_state` has to be JSON: plain hashes,
|
|
27
|
+
# arrays, strings, numbers, booleans, and nil. We check that here, at snapshot time, because a
|
|
28
|
+
# value that JSON would quietly turn into something else (a Time into a string, a record into
|
|
29
|
+
# its attributes) is a bug that would otherwise only show up in `restore_state` on some other
|
|
30
|
+
# machine.
|
|
31
|
+
module Snapshot
|
|
32
|
+
VERSION = 1
|
|
33
|
+
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
# `snapshot_state` returned something JSON can't carry faithfully.
|
|
37
|
+
class NotSerializable < Error; end
|
|
38
|
+
|
|
39
|
+
# The snapshot was written by a gem version this one doesn't understand.
|
|
40
|
+
class UnknownVersion < Error; end
|
|
41
|
+
|
|
42
|
+
# The snapshot doesn't name a room class this process knows.
|
|
43
|
+
class UnknownRoomClass < Error; end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# The snapshot of `room`, already round-tripped through JSON, so what you get back is
|
|
47
|
+
# exactly what a restore will see after a trip through Redis.
|
|
48
|
+
def take(room)
|
|
49
|
+
room.send(:_snapshot)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check a snapshot before anything is built from it. Returns the snapshot with string keys,
|
|
53
|
+
# so callers can read it the same way whether it came straight from `take` or from Redis.
|
|
54
|
+
def validate!(snapshot)
|
|
55
|
+
snapshot = snapshot.to_h.deep_stringify_keys
|
|
56
|
+
version = snapshot["version"]
|
|
57
|
+
unless version == VERSION
|
|
58
|
+
raise UnknownVersion, "Snapshot version #{version.inspect} isn't supported (this gem writes version #{VERSION})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
snapshot
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def room_class_for(snapshot)
|
|
65
|
+
name = snapshot["room_class"]
|
|
66
|
+
klass = name.to_s.safe_constantize
|
|
67
|
+
unless klass.is_a?(Class) && klass <= Room::Base
|
|
68
|
+
raise UnknownRoomClass, "Snapshot names #{name.inspect}, which isn't a CableRoom::Room::Base subclass here"
|
|
69
|
+
end
|
|
70
|
+
klass
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def key_for(snapshot)
|
|
74
|
+
deserialize_argument(snapshot["key"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Encode and decode the whole document, so the result is the post-JSON shape (string keys,
|
|
78
|
+
# ISO8601 times) rather than the Ruby objects the room held.
|
|
79
|
+
def round_trip(document)
|
|
80
|
+
ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(document))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Walk `value` and raise NotSerializable at the first thing JSON can't carry unchanged. Hash
|
|
84
|
+
# keys may be symbols (they come back as strings, and restore_state gets an indifferent-access
|
|
85
|
+
# hash); symbol *values* are refused because they'd come back as plain strings.
|
|
86
|
+
def assert_json!(value, room:, path: "app_state")
|
|
87
|
+
case value
|
|
88
|
+
when nil, true, false, String, Integer
|
|
89
|
+
nil
|
|
90
|
+
when Float
|
|
91
|
+
raise_not_serializable(room, path, "#{value} isn't a finite number") unless value.finite?
|
|
92
|
+
when Hash
|
|
93
|
+
value.each do |k, v|
|
|
94
|
+
unless k.is_a?(String) || k.is_a?(Symbol)
|
|
95
|
+
raise_not_serializable(room, path, "has a #{k.class} key (#{k.inspect}); JSON object keys must be strings")
|
|
96
|
+
end
|
|
97
|
+
assert_json!(v, room: room, path: "#{path}[#{k.inspect}]")
|
|
98
|
+
end
|
|
99
|
+
when Array
|
|
100
|
+
value.each_with_index { |v, i| assert_json!(v, room: room, path: "#{path}[#{i}]") }
|
|
101
|
+
when Symbol
|
|
102
|
+
raise_not_serializable(room, path, "is the Symbol #{value.inspect}; JSON has no symbols, so restore_state would get a String back. Use a string")
|
|
103
|
+
else
|
|
104
|
+
raise_not_serializable(room, path, "is a #{value.class}, which JSON can't carry. Reduce it to hashes, arrays, strings, numbers, booleans, and nil (records by id)")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Room keys and users go through the same serializer members use on the wire
|
|
109
|
+
# (RoomMembership#transmit_port_connected), so a record travels as its GlobalID and
|
|
110
|
+
# strings, numbers, and symbols come back as themselves.
|
|
111
|
+
def serialize_argument(value)
|
|
112
|
+
::ActiveJob::Arguments.serialize([value]).first
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def deserialize_argument(value)
|
|
116
|
+
::ActiveJob::Arguments.deserialize([value]).first
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encode_time(time)
|
|
120
|
+
time&.getutc&.iso8601(6)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decode_time(value)
|
|
124
|
+
return nil if value.blank?
|
|
125
|
+
value.is_a?(Time) ? value : Time.iso8601(value)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def raise_not_serializable(room, path, detail)
|
|
131
|
+
raise NotSerializable,
|
|
132
|
+
"#{room.class.name}[#{room.key.inspect}]: snapshot_state returned something that isn't JSON: #{path} #{detail}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/cable_room/version.rb
CHANGED
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/
|
|
13
|
-
|
|
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.
|
|
4
|
+
version: 0.7.0.beta1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Knapp
|
|
8
|
-
bindir:
|
|
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: :
|
|
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,48 @@ 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/
|
|
112
|
-
- lib/cable_room/
|
|
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/
|
|
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
|
|
128
143
|
- lib/cable_room/room_member.rb
|
|
129
144
|
- lib/cable_room/room_proxy_channel.rb
|
|
145
|
+
- lib/cable_room/snapshot.rb
|
|
130
146
|
- lib/cable_room/version.rb
|
|
131
147
|
homepage: https://instructure.com
|
|
132
148
|
licenses: []
|
|
@@ -1,262 +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(...) # TODO Try making sync
|
|
47
|
-
# raise ArgumentError, "Block required" unless block_given?
|
|
48
|
-
# super
|
|
49
|
-
# end
|
|
50
|
-
|
|
51
|
-
def stream_from(broadcasting, callback = nil, coder: nil, &block)
|
|
52
|
-
raise ArgumentError, "Block required" unless block.present?
|
|
53
|
-
|
|
54
|
-
broadcasting = String(broadcasting)
|
|
55
|
-
|
|
56
|
-
# Build a stream handler by wrapping the user-provided callback with a decoder
|
|
57
|
-
# or defaulting to a JSON-decoding retransmitter.
|
|
58
|
-
handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
|
|
59
|
-
streams[broadcasting] = handler
|
|
60
|
-
|
|
61
|
-
pubsub.subscribe(broadcasting, handler, lambda do
|
|
62
|
-
logger.info "#{self.class.name} is streaming from #{broadcasting}"
|
|
63
|
-
end)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def state
|
|
67
|
-
@current_state
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
after_subscribe do
|
|
71
|
-
begin
|
|
72
|
-
@current_state = :starting
|
|
73
|
-
@room.send(:_startup)
|
|
74
|
-
@current_state = :started
|
|
75
|
-
rescue => e
|
|
76
|
-
terminate!
|
|
77
|
-
raise e
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
after_unsubscribe do
|
|
82
|
-
@mutex.synchronize do
|
|
83
|
-
@current_state = :shutting_down
|
|
84
|
-
stop_all_streams
|
|
85
|
-
begin
|
|
86
|
-
@room.send(:_shutdown)
|
|
87
|
-
ensure
|
|
88
|
-
terminate!
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def ping_watchdog
|
|
94
|
-
return if state == :dead
|
|
95
|
-
|
|
96
|
-
logger.debug "Ping watchdog"
|
|
97
|
-
@last_watchdog_ping_at = Time.current
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def check_room_watchdog
|
|
101
|
-
@mutex.synchronize do
|
|
102
|
-
return if state == :dead || state == :shutting_down
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
relock = CableRoom.lock_manager.lock(@lock_info[:resource], @lock_duration.in_milliseconds, extend: @lock_info)
|
|
106
|
-
unless relock
|
|
107
|
-
logger.warn "Lost lock, shutting down"
|
|
108
|
-
unsubscribe_from_channel
|
|
109
|
-
return
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
|
|
113
|
-
logger.warn "Watchdog timeout for room #{@room.class.name}[#{@room.key}], shutting down"
|
|
114
|
-
initiate_shutdown("Watchdog timeout")
|
|
115
|
-
return
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def initiate_shutdown(reason)
|
|
120
|
-
@mutex.synchronize do
|
|
121
|
-
return if @current_state == :dead || @current_state == :shutting_down
|
|
122
|
-
|
|
123
|
-
logger.info "Initiating shutdown: #{reason}"
|
|
124
|
-
|
|
125
|
-
# Stop streams immediately to prevent further messages from being added
|
|
126
|
-
stop_all_streams
|
|
127
|
-
|
|
128
|
-
# Append the final unsubscribe to the work queue so we can process remaining messages first
|
|
129
|
-
post_work(async: false) do
|
|
130
|
-
unsubscribe_from_channel
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
@current_state = :shutting_down
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def terminate!
|
|
138
|
-
@mutex.synchronize do
|
|
139
|
-
stop_all_streams
|
|
140
|
-
@current_state = :dead
|
|
141
|
-
CableRoom.lock_manager.unlock(@lock_info) if @lock_info
|
|
142
|
-
server.untrack_room_channel self
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def _post_wrapped_work(async: false, silent: false, &blk)
|
|
147
|
-
if async
|
|
148
|
-
# Async stuff is mostly untracked - we just post it to the worker pool and forget about it
|
|
149
|
-
worker_pool.executor.post(&blk)
|
|
150
|
-
else
|
|
151
|
-
@mutex.synchronize do
|
|
152
|
-
if @current_state == :dead || @current_state == :shutting_down
|
|
153
|
-
raise "Attempt to post work to dead or shutting down room" unless silent
|
|
154
|
-
return
|
|
155
|
-
end
|
|
156
|
-
@work_queue << blk
|
|
157
|
-
end
|
|
158
|
-
schedule_work
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def post_work(**kwargs, &blk)
|
|
163
|
-
_post_wrapped_work(**kwargs) do
|
|
164
|
-
worker_pool.invoke(self, :instance_exec, connection: self, &blk)
|
|
165
|
-
rescue => e
|
|
166
|
-
report_work_error(e)
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
# Work errors are swallowed so one bad message can't take the Room down with it. Log the
|
|
171
|
-
# backtrace and hand the error to the application so the failure is still discoverable.
|
|
172
|
-
def report_work_error(error)
|
|
173
|
-
logger.error "Error during work execution: #{error.class.name}: #{error.message}"
|
|
174
|
-
Array(error.backtrace).first(20).each { |line| logger.error " #{line}" }
|
|
175
|
-
|
|
176
|
-
CableRoom.report_error(
|
|
177
|
-
error,
|
|
178
|
-
room: room,
|
|
179
|
-
room_class: room&.class,
|
|
180
|
-
room_key: room&.key,
|
|
181
|
-
channel: self
|
|
182
|
-
)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def beat
|
|
186
|
-
post_work(async: true) do
|
|
187
|
-
check_room_watchdog
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def transmit(*args)
|
|
192
|
-
logger.info("Channel.transmit called, ignoring: #{args.inspect}")
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
protected
|
|
196
|
-
|
|
197
|
-
def start_periodic_timer(callback, every:)
|
|
198
|
-
raise "Attempt to start periodic timer on a dead room" if state == :dead || state == :shutting_down
|
|
199
|
-
|
|
200
|
-
job = connection.server.scheduler.schedule_every(every) do
|
|
201
|
-
post_work(async: false, silent: true) do
|
|
202
|
-
instance_exec(&callback)
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
PeriodicTimer.new(job)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def schedule_work
|
|
210
|
-
@mutex.synchronize do
|
|
211
|
-
return if @processing_work
|
|
212
|
-
|
|
213
|
-
work = @work_queue.shift
|
|
214
|
-
return unless work
|
|
215
|
-
|
|
216
|
-
@processing_work = true
|
|
217
|
-
|
|
218
|
-
worker_pool.executor.post do
|
|
219
|
-
begin
|
|
220
|
-
work.call
|
|
221
|
-
ensure
|
|
222
|
-
@mutex.synchronize do
|
|
223
|
-
@processing_work = false
|
|
224
|
-
end
|
|
225
|
-
schedule_work
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
# ActionCable's `stop_periodic_timers` calls #shutdown on whatever #start_periodic_timer
|
|
233
|
-
# returned, but Rufus jobs are cancelled with #unschedule. Adapt the one to the other here
|
|
234
|
-
# rather than aliasing #shutdown onto Rufus::Scheduler::Job for the whole process.
|
|
235
|
-
class PeriodicTimer
|
|
236
|
-
attr_reader :job
|
|
237
|
-
|
|
238
|
-
delegate :unschedule, :scheduled?, :next_time, to: :job
|
|
239
|
-
|
|
240
|
-
def initialize(job)
|
|
241
|
-
@job = job
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def shutdown
|
|
245
|
-
job.unschedule
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
class DummyConnection
|
|
250
|
-
attr_reader :channel
|
|
251
|
-
delegate :server, :logger, :tenant, :transmit, :post_work, :_post_wrapped_work,
|
|
252
|
-
:report_work_error, to: :channel
|
|
253
|
-
delegate :event_loop, :pubsub, :worker_pool, to: :server
|
|
254
|
-
|
|
255
|
-
attr_reader :identifiers
|
|
256
|
-
|
|
257
|
-
def initialize(channel)
|
|
258
|
-
@channel = channel
|
|
259
|
-
@identifiers = []
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|