anycable 0.6.4 → 1.0.0.rc2
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 +24 -236
- data/MIT-LICENSE +1 -1
- data/README.md +17 -16
- data/lib/anycable.rb +3 -12
- data/lib/anycable/broadcast_adapters.rb +4 -2
- data/lib/anycable/broadcast_adapters/base.rb +29 -0
- data/lib/anycable/broadcast_adapters/http.rb +130 -0
- data/lib/anycable/broadcast_adapters/redis.rb +7 -6
- data/lib/anycable/cli.rb +35 -11
- data/lib/anycable/config.rb +44 -21
- data/lib/anycable/exceptions_handling.rb +5 -7
- data/lib/anycable/health_server.rb +4 -2
- data/lib/anycable/middleware.rb +9 -1
- data/lib/anycable/middleware_chain.rb +2 -2
- data/lib/anycable/middlewares/check_version.rb +24 -0
- data/lib/anycable/rpc.rb +84 -0
- data/lib/anycable/rpc/rpc_pb.rb +57 -39
- data/lib/anycable/rpc/rpc_services_pb.rb +4 -3
- data/lib/anycable/rpc_handler.rb +44 -24
- data/lib/anycable/rspec.rb +6 -0
- data/lib/anycable/rspec/rpc_command_context.rb +20 -0
- data/lib/anycable/rspec/rpc_stub_context.rb +13 -0
- data/lib/anycable/rspec/with_grpc_server.rb +15 -0
- data/lib/anycable/server.rb +6 -62
- data/lib/anycable/socket.rb +48 -6
- data/lib/anycable/version.rb +1 -1
- metadata +21 -41
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "uri"
|
5
|
+
require "net/http"
|
6
|
+
|
7
|
+
module AnyCable
|
8
|
+
module BroadcastAdapters
|
9
|
+
# HTTP adapter for broadcasting.
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# AnyCable.broadast_adapter = :http
|
14
|
+
#
|
15
|
+
# It uses configuration from global AnyCable config
|
16
|
+
# by default.
|
17
|
+
#
|
18
|
+
# You can override these params:
|
19
|
+
#
|
20
|
+
# AnyCable.broadcast_adapter = :http, url: "http://ws.example.com/_any_cable_"
|
21
|
+
class Http < Base
|
22
|
+
# Taken from: https://github.com/influxdata/influxdb-ruby/blob/886058079c66d4fd019ad74ca11342fddb0b753d/lib/influxdb/errors.rb#L18
|
23
|
+
RECOVERABLE_EXCEPTIONS = [
|
24
|
+
Errno::ECONNABORTED,
|
25
|
+
Errno::ECONNREFUSED,
|
26
|
+
Errno::ECONNRESET,
|
27
|
+
Errno::EHOSTUNREACH,
|
28
|
+
Errno::EINVAL,
|
29
|
+
Errno::ENETUNREACH,
|
30
|
+
Net::HTTPBadResponse,
|
31
|
+
Net::HTTPHeaderSyntaxError,
|
32
|
+
Net::ProtocolError,
|
33
|
+
SocketError,
|
34
|
+
(OpenSSL::SSL::SSLError if defined?(OpenSSL))
|
35
|
+
].compact.freeze
|
36
|
+
|
37
|
+
OPEN_TIMEOUT = 5
|
38
|
+
READ_TIMEOUT = 10
|
39
|
+
|
40
|
+
MAX_ATTEMPTS = 3
|
41
|
+
DELAY = 2
|
42
|
+
|
43
|
+
attr_reader :url, :headers, :authorized
|
44
|
+
alias authorized? authorized
|
45
|
+
|
46
|
+
def initialize(url: AnyCable.config.http_broadcast_url, secret: AnyCable.config.http_broadcast_secret)
|
47
|
+
@url = url
|
48
|
+
@headers = {}
|
49
|
+
if secret
|
50
|
+
headers["Authorization"] = "Bearer #{secret}"
|
51
|
+
@authorized = true
|
52
|
+
end
|
53
|
+
|
54
|
+
@uri = URI.parse(url)
|
55
|
+
@queue = Queue.new
|
56
|
+
end
|
57
|
+
|
58
|
+
def raw_broadcast(payload)
|
59
|
+
ensure_thread_is_alive
|
60
|
+
queue << payload
|
61
|
+
end
|
62
|
+
|
63
|
+
# Wait for background thread to process all the messages
|
64
|
+
# and stop it
|
65
|
+
def shutdown
|
66
|
+
queue << :stop
|
67
|
+
thread.join if thread&.alive?
|
68
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
69
|
+
logger.error "Broadcasting thread exited with exception: #{e.message}"
|
70
|
+
end
|
71
|
+
|
72
|
+
def announce!
|
73
|
+
logger.info "Broadcasting HTTP url: #{url}#{authorized? ? " (with authorization)" : ""}"
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
attr_reader :uri, :queue, :thread
|
79
|
+
|
80
|
+
def ensure_thread_is_alive
|
81
|
+
return if thread&.alive?
|
82
|
+
|
83
|
+
@thread = Thread.new do
|
84
|
+
loop do
|
85
|
+
msg = queue.pop
|
86
|
+
break if msg == :stop
|
87
|
+
|
88
|
+
handle_response perform_request(msg)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def perform_request(payload)
|
94
|
+
build_http do |http|
|
95
|
+
req = Net::HTTP::Post.new(url, {"Content-Type" => "application/json"}.merge(headers))
|
96
|
+
req.body = payload
|
97
|
+
http.request(req)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def handle_response(response)
|
102
|
+
return unless response
|
103
|
+
return if Net::HTTPCreated === response
|
104
|
+
|
105
|
+
logger.error "Broadcast request responded with unexpected status: #{response.code}"
|
106
|
+
end
|
107
|
+
|
108
|
+
def build_http
|
109
|
+
retry_count = 0
|
110
|
+
|
111
|
+
begin
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
113
|
+
http.open_timeout = OPEN_TIMEOUT
|
114
|
+
http.read_timeout = READ_TIMEOUT
|
115
|
+
http.use_ssl = url.match?(/^https/)
|
116
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
117
|
+
yield http
|
118
|
+
rescue Timeout::Error, *RECOVERABLE_EXCEPTIONS => e
|
119
|
+
retry_count += 1
|
120
|
+
return logger.error("Broadcast request failed: #{e.message}") if MAX_ATTEMPTS < retry_count
|
121
|
+
|
122
|
+
sleep((DELAY**retry_count) * retry_count)
|
123
|
+
retry
|
124
|
+
ensure
|
125
|
+
http.finish if http.started?
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -19,7 +19,7 @@ module AnyCable
|
|
19
19
|
# You can override these params:
|
20
20
|
#
|
21
21
|
# AnyCable.broadcast_adapter = :redis, url: "redis://my_redis", channel: "_any_cable_"
|
22
|
-
class Redis
|
22
|
+
class Redis < Base
|
23
23
|
attr_reader :redis_conn, :channel
|
24
24
|
|
25
25
|
def initialize(
|
@@ -31,11 +31,12 @@ module AnyCable
|
|
31
31
|
@channel = channel
|
32
32
|
end
|
33
33
|
|
34
|
-
def
|
35
|
-
redis_conn.publish(
|
36
|
-
|
37
|
-
|
38
|
-
|
34
|
+
def raw_broadcast(payload)
|
35
|
+
redis_conn.publish(channel, payload)
|
36
|
+
end
|
37
|
+
|
38
|
+
def announce!
|
39
|
+
logger.info "Broadcasting Redis channel: #{channel}"
|
39
40
|
end
|
40
41
|
end
|
41
42
|
end
|
data/lib/anycable/cli.rb
CHANGED
@@ -20,27 +20,32 @@ module AnyCable
|
|
20
20
|
# Wait for external process termination (s)
|
21
21
|
WAIT_PROCESS = 2
|
22
22
|
|
23
|
-
attr_reader :server, :health_server
|
23
|
+
attr_reader :server, :health_server, :embedded
|
24
|
+
alias embedded? embedded
|
25
|
+
|
26
|
+
def initialize(embedded: false)
|
27
|
+
@embedded = embedded
|
28
|
+
end
|
24
29
|
|
25
30
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
26
|
-
def run(args =
|
31
|
+
def run(args = [])
|
27
32
|
@at_stop = []
|
28
33
|
|
29
34
|
extra_options = parse_cli_options!(args)
|
30
35
|
|
31
36
|
# Boot app first, 'cause it might change
|
32
37
|
# configuration, loggin settings, etc.
|
33
|
-
boot_app!
|
38
|
+
boot_app! unless embedded?
|
34
39
|
|
35
40
|
parse_gem_options!(extra_options)
|
36
41
|
|
37
42
|
configure_server!
|
38
43
|
|
39
|
-
logger.info "Starting AnyCable gRPC server (pid: #{Process.pid})"
|
44
|
+
logger.info "Starting AnyCable gRPC server (pid: #{Process.pid}, workers_num: #{config.rpc_pool_size})"
|
40
45
|
|
41
46
|
print_versions!
|
42
47
|
|
43
|
-
logger.info "Serving #{defined?(::Rails) ?
|
48
|
+
logger.info "Serving #{defined?(::Rails) ? "Rails " : ""}application from #{boot_file}" unless embedded?
|
44
49
|
|
45
50
|
verify_connection_factory!
|
46
51
|
|
@@ -48,6 +53,8 @@ module AnyCable
|
|
48
53
|
|
49
54
|
log_errors!
|
50
55
|
|
56
|
+
use_version_check! if config.version_check_enabled?
|
57
|
+
|
51
58
|
@server = AnyCable::Server.new(
|
52
59
|
host: config.rpc_host,
|
53
60
|
**config.to_grpc_params,
|
@@ -64,6 +71,8 @@ module AnyCable
|
|
64
71
|
|
65
72
|
run_custom_server_command! unless server_command.nil?
|
66
73
|
|
74
|
+
return if embedded?
|
75
|
+
|
67
76
|
begin
|
68
77
|
wait_till_terminated
|
69
78
|
rescue Interrupt => e
|
@@ -79,7 +88,7 @@ module AnyCable
|
|
79
88
|
|
80
89
|
def shutdown
|
81
90
|
at_stop.each(&:call)
|
82
|
-
server
|
91
|
+
server&.stop
|
83
92
|
end
|
84
93
|
|
85
94
|
private
|
@@ -124,7 +133,7 @@ module AnyCable
|
|
124
133
|
end
|
125
134
|
|
126
135
|
def print_versions!
|
127
|
-
logger.info "AnyCable version: #{AnyCable::VERSION}"
|
136
|
+
logger.info "AnyCable version: #{AnyCable::VERSION} (proto_version: #{AnyCable::PROTO_VERSION})"
|
128
137
|
logger.info "gRPC version: #{GRPC::VERSION}"
|
129
138
|
end
|
130
139
|
|
@@ -162,6 +171,14 @@ module AnyCable
|
|
162
171
|
AnyCable.server_callbacks.each(&:call)
|
163
172
|
end
|
164
173
|
|
174
|
+
def use_version_check!
|
175
|
+
require "anycable/middlewares/check_version"
|
176
|
+
|
177
|
+
AnyCable.middleware.use(
|
178
|
+
AnyCable::Middlewares::CheckVersion.new(AnyCable::PROTO_VERSION)
|
179
|
+
)
|
180
|
+
end
|
181
|
+
|
165
182
|
def start_health_server!
|
166
183
|
@health_server = AnyCable::HealthServer.new(
|
167
184
|
server,
|
@@ -173,7 +190,7 @@ module AnyCable
|
|
173
190
|
end
|
174
191
|
|
175
192
|
def start_pubsub!
|
176
|
-
|
193
|
+
AnyCable.broadcast_adapter.announce!
|
177
194
|
end
|
178
195
|
|
179
196
|
# rubocop: disable Metrics/MethodLength, Metrics/AbcSize
|
@@ -303,9 +320,7 @@ module AnyCable
|
|
303
320
|
-r, --require=path Location of application file to require, default: "config/environment.rb"
|
304
321
|
--server-command=command Command to run WebSocket server
|
305
322
|
--rpc-host=host Local address to run gRPC server on, default: "[::]:50051"
|
306
|
-
--
|
307
|
-
--redis-channel=name Redis channel for broadcasting, default: "__anycable__"
|
308
|
-
--redis-sentinels=<...hosts> Redis Sentinel followers addresses (as a comma-separated list), default: nil
|
323
|
+
--broadcast-adapter=type Pub/sub adapter type for broadcasts, default: redis
|
309
324
|
--log-level=level Logging level, default: "info"
|
310
325
|
--log-file=path Path to log file, default: <none> (log to STDOUT)
|
311
326
|
--log-grpc Enable gRPC logging (disabled by default)
|
@@ -313,6 +328,15 @@ module AnyCable
|
|
313
328
|
-v, --version Print version and exit
|
314
329
|
-h, --help Show this help
|
315
330
|
|
331
|
+
REDIS PUB/SUB OPTIONS
|
332
|
+
--redis-url=url Redis URL for pub/sub, default: REDIS_URL or "redis://localhost:6379/5"
|
333
|
+
--redis-channel=name Redis channel for broadcasting, default: "__anycable__"
|
334
|
+
--redis-sentinels=<...hosts> Redis Sentinel followers addresses (as a comma-separated list), default: nil
|
335
|
+
|
336
|
+
HTTP PUB/SUB OPTIONS
|
337
|
+
--http-broadcast-url HTTP pub/sub endpoint URL, default: "http://localhost:8090/_broadcast"
|
338
|
+
--http-broadcast-secret HTTP pub/sub authorization secret, default: <none> (disabled)
|
339
|
+
|
316
340
|
HTTP HEALTH CHECKER OPTIONS
|
317
341
|
--http-health-port=port Port to run HTTP health server on, default: <none> (disabled)
|
318
342
|
--http-health-path=path Endpoint to server health cheks, default: "/health"
|
data/lib/anycable/config.rb
CHANGED
@@ -8,11 +8,9 @@ module AnyCable
|
|
8
8
|
class Config < Anyway::Config
|
9
9
|
config_name :anycable
|
10
10
|
|
11
|
-
DefaultHostWrapper = Class.new(String)
|
12
|
-
|
13
11
|
attr_config(
|
14
12
|
### gRPC options
|
15
|
-
rpc_host:
|
13
|
+
rpc_host: "127.0.0.1:50051",
|
16
14
|
# For defaults see https://github.com/grpc/grpc/blob/51f0d35509bcdaba572d422c4f856208162022de/src/ruby/lib/grpc/generic/rpc_server.rb#L186-L216
|
17
15
|
rpc_pool_size: GRPC::RpcServer::DEFAULT_POOL_SIZE,
|
18
16
|
rpc_max_waiting_requests: GRPC::RpcServer::DEFAULT_MAX_WAITING_REQUESTS,
|
@@ -21,11 +19,18 @@ module AnyCable
|
|
21
19
|
# See https://github.com/grpc/grpc/blob/f526602bff029b8db50a8d57134d72da33d8a752/include/grpc/impl/codegen/grpc_types.h#L292-L315
|
22
20
|
rpc_server_args: {},
|
23
21
|
|
22
|
+
## PubSub
|
23
|
+
broadcast_adapter: :redis,
|
24
|
+
|
24
25
|
### Redis options
|
25
26
|
redis_url: ENV.fetch("REDIS_URL", "redis://localhost:6379/5"),
|
26
27
|
redis_sentinels: nil,
|
27
28
|
redis_channel: "__anycable__",
|
28
29
|
|
30
|
+
### HTTP broadcasting options
|
31
|
+
http_broadcast_url: "http://localhost:8090/_broadcast",
|
32
|
+
http_broadcast_secret: nil,
|
33
|
+
|
29
34
|
### Logging options
|
30
35
|
log_file: nil,
|
31
36
|
log_level: :info,
|
@@ -34,25 +39,44 @@ module AnyCable
|
|
34
39
|
|
35
40
|
### Health check options
|
36
41
|
http_health_port: nil,
|
37
|
-
http_health_path: "/health"
|
42
|
+
http_health_path: "/health",
|
43
|
+
|
44
|
+
### Misc options
|
45
|
+
version_check_enabled: true
|
38
46
|
)
|
39
47
|
|
48
|
+
alias version_check_enabled? version_check_enabled
|
49
|
+
|
40
50
|
ignore_options :rpc_server_args
|
41
51
|
flag_options :log_grpc, :debug
|
42
52
|
|
43
|
-
|
44
|
-
|
45
|
-
|
53
|
+
# Support both anyway_config 1.4 and 2.0.
|
54
|
+
# DEPRECATE: Drop <2.0 support in 1.1
|
55
|
+
if respond_to?(:on_load)
|
56
|
+
on_load { self.debug = debug != false }
|
46
57
|
|
47
|
-
|
48
|
-
|
49
|
-
|
58
|
+
def log_level
|
59
|
+
debug? ? :debug : super
|
60
|
+
end
|
50
61
|
|
51
|
-
|
52
|
-
|
53
|
-
|
62
|
+
def log_grpc
|
63
|
+
debug? || super
|
64
|
+
end
|
65
|
+
else
|
66
|
+
def log_level
|
67
|
+
debug ? :debug : @log_level
|
68
|
+
end
|
69
|
+
|
70
|
+
def log_grpc
|
71
|
+
debug || @log_grpc
|
72
|
+
end
|
73
|
+
|
74
|
+
def debug
|
75
|
+
@debug != false
|
76
|
+
end
|
54
77
|
|
55
|
-
|
78
|
+
alias debug? debug
|
79
|
+
end
|
56
80
|
|
57
81
|
def http_health_port_provided?
|
58
82
|
!http_health_port.nil? && http_health_port != ""
|
@@ -71,15 +95,14 @@ module AnyCable
|
|
71
95
|
|
72
96
|
# Build Redis parameters
|
73
97
|
def to_redis_params
|
74
|
-
{
|
98
|
+
{url: redis_url}.tap do |params|
|
75
99
|
next if redis_sentinels.nil?
|
76
100
|
|
77
|
-
|
78
|
-
redis_sentinels.is_a?(Array)
|
101
|
+
sentinels = Array(redis_sentinels)
|
79
102
|
|
80
|
-
next if
|
103
|
+
next if sentinels.empty?
|
81
104
|
|
82
|
-
params[:sentinels] =
|
105
|
+
params[:sentinels] = sentinels.map(&method(:parse_sentinel))
|
83
106
|
end
|
84
107
|
end
|
85
108
|
|
@@ -96,13 +119,13 @@ module AnyCable
|
|
96
119
|
SENTINEL_RXP = /^([\w\-_]*)\:(\d+)$/.freeze
|
97
120
|
|
98
121
|
def parse_sentinel(sentinel)
|
99
|
-
return sentinel if sentinel.is_a?(Hash)
|
122
|
+
return sentinel.transform_keys!(&:to_sym) if sentinel.is_a?(Hash)
|
100
123
|
|
101
124
|
matches = sentinel.match(SENTINEL_RXP)
|
102
125
|
|
103
126
|
raise ArgumentError, "Invalid Sentinel value: #{sentinel}" if matches.nil?
|
104
127
|
|
105
|
-
{
|
128
|
+
{host: matches[1], port: matches[2].to_i}
|
106
129
|
end
|
107
130
|
end
|
108
131
|
end
|
@@ -11,13 +11,11 @@ module AnyCable
|
|
11
11
|
|
12
12
|
def notify(exp, method_name, message)
|
13
13
|
handlers.each do |handler|
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
AnyCable.logger.error exp.backtrace.join("\n") unless exp.backtrace.nil?
|
20
|
-
end
|
14
|
+
handler.call(exp, method_name, message)
|
15
|
+
rescue => exp
|
16
|
+
AnyCable.logger.error "!!! EXCEPTION HANDLER THREW AN ERROR !!!"
|
17
|
+
AnyCable.logger.error exp
|
18
|
+
AnyCable.logger.error exp.backtrace.join("\n") unless exp.backtrace.nil?
|
21
19
|
end
|
22
20
|
end
|
23
21
|
|
@@ -20,7 +20,7 @@ module AnyCable
|
|
20
20
|
|
21
21
|
attr_reader :grpc_server, :port, :path, :server
|
22
22
|
|
23
|
-
def initialize(grpc_server, port:, path: "/health"
|
23
|
+
def initialize(grpc_server, port:, logger: nil, path: "/health")
|
24
24
|
@grpc_server = grpc_server
|
25
25
|
@port = port
|
26
26
|
@path = path
|
@@ -48,7 +48,9 @@ module AnyCable
|
|
48
48
|
|
49
49
|
private
|
50
50
|
|
51
|
-
|
51
|
+
def logger
|
52
|
+
@logger ||= AnyCable.logger
|
53
|
+
end
|
52
54
|
|
53
55
|
def build_server
|
54
56
|
require "webrick"
|
data/lib/anycable/middleware.rb
CHANGED
@@ -5,13 +5,21 @@ require "grpc"
|
|
5
5
|
module AnyCable
|
6
6
|
# Middleware is a wrapper over gRPC interceptors
|
7
7
|
# for request/response calls
|
8
|
-
class Middleware < GRPC::
|
8
|
+
class Middleware < GRPC::ServerInterceptor
|
9
9
|
def request_response(request: nil, call: nil, method: nil)
|
10
|
+
# Call middlewares only for AnyCable service
|
11
|
+
return yield unless method.receiver.is_a?(AnyCable::RPCHandler)
|
12
|
+
|
10
13
|
call(request, call, method) do
|
11
14
|
yield
|
12
15
|
end
|
13
16
|
end
|
14
17
|
|
18
|
+
def server_streamer(**kwargs)
|
19
|
+
p kwargs
|
20
|
+
yield
|
21
|
+
end
|
22
|
+
|
15
23
|
def call(*)
|
16
24
|
raise NotImplementedError
|
17
25
|
end
|