anycable 0.6.3 → 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 broadcast(stream, payload)
35
- redis_conn.publish(
36
- channel,
37
- { stream: stream, data: payload }.to_json
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
@@ -36,11 +36,11 @@ module AnyCable
36
36
 
37
37
  configure_server!
38
38
 
39
- logger.info "Starting AnyCable gRPC server (pid: #{Process.pid})"
39
+ logger.info "Starting AnyCable gRPC server (pid: #{Process.pid}, workers_num: #{config.rpc_pool_size})"
40
40
 
41
41
  print_versions!
42
42
 
43
- logger.info "Serving #{defined?(::Rails) ? 'Rails ' : ''}application from #{boot_file}"
43
+ logger.info "Serving #{defined?(::Rails) ? "Rails " : ""}application from #{boot_file}"
44
44
 
45
45
  verify_connection_factory!
46
46
 
@@ -48,6 +48,8 @@ module AnyCable
48
48
 
49
49
  log_errors!
50
50
 
51
+ use_version_check! if config.version_check_enabled?
52
+
51
53
  @server = AnyCable::Server.new(
52
54
  host: config.rpc_host,
53
55
  **config.to_grpc_params,
@@ -124,7 +126,7 @@ module AnyCable
124
126
  end
125
127
 
126
128
  def print_versions!
127
- logger.info "AnyCable version: #{AnyCable::VERSION}"
129
+ logger.info "AnyCable version: #{AnyCable::VERSION} (proto_version: #{AnyCable::PROTO_VERSION})"
128
130
  logger.info "gRPC version: #{GRPC::VERSION}"
129
131
  end
130
132
 
@@ -162,6 +164,14 @@ module AnyCable
162
164
  AnyCable.server_callbacks.each(&:call)
163
165
  end
164
166
 
167
+ def use_version_check!
168
+ require "anycable/middlewares/check_version"
169
+
170
+ AnyCable.middleware.use(
171
+ AnyCable::Middlewares::CheckVersion.new(AnyCable::PROTO_VERSION)
172
+ )
173
+ end
174
+
165
175
  def start_health_server!
166
176
  @health_server = AnyCable::HealthServer.new(
167
177
  server,
@@ -173,7 +183,7 @@ module AnyCable
173
183
  end
174
184
 
175
185
  def start_pubsub!
176
- logger.info "Broadcasting Redis channel: #{config.redis_channel}"
186
+ AnyCable.broadcast_adapter.announce!
177
187
  end
178
188
 
179
189
  # rubocop: disable Metrics/MethodLength, Metrics/AbcSize
@@ -303,9 +313,7 @@ module AnyCable
303
313
  -r, --require=path Location of application file to require, default: "config/environment.rb"
304
314
  --server-command=command Command to run WebSocket server
305
315
  --rpc-host=host Local address to run gRPC server on, default: "[::]:50051"
306
- --redis-url=url Redis URL for pub/sub, default: REDIS_URL or "redis://localhost:6379/5"
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
316
+ --broadcast-adapter=type Pub/sub adapter type for broadcasts, default: redis
309
317
  --log-level=level Logging level, default: "info"
310
318
  --log-file=path Path to log file, default: <none> (log to STDOUT)
311
319
  --log-grpc Enable gRPC logging (disabled by default)
@@ -313,6 +321,15 @@ module AnyCable
313
321
  -v, --version Print version and exit
314
322
  -h, --help Show this help
315
323
 
324
+ REDIS PUB/SUB OPTIONS
325
+ --redis-url=url Redis URL for pub/sub, default: REDIS_URL or "redis://localhost:6379/5"
326
+ --redis-channel=name Redis channel for broadcasting, default: "__anycable__"
327
+ --redis-sentinels=<...hosts> Redis Sentinel followers addresses (as a comma-separated list), default: nil
328
+
329
+ HTTP PUB/SUB OPTIONS
330
+ --http-broadcast-url HTTP pub/sub endpoint URL, default: "http://localhost:8090/_broadcast"
331
+ --http-broadcast-secret HTTP pub/sub authorization secret, default: <none> (disabled)
332
+
316
333
  HTTP HEALTH CHECKER OPTIONS
317
334
  --http-health-port=port Port to run HTTP health server on, default: <none> (disabled)
318
335
  --http-health-path=path Endpoint to server health cheks, default: "/health"
@@ -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: DefaultHostWrapper.new("[::]:50051"),
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
- def log_level
44
- debug ? :debug : @log_level
45
- end
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
- def log_grpc
48
- debug || @log_grpc
49
- end
58
+ def log_level
59
+ debug? ? :debug : super
60
+ end
50
61
 
51
- def debug
52
- @debug != false
53
- end
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
- alias debug? debug
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
- { url: redis_url }.tap do |params|
98
+ {url: redis_url}.tap do |params|
75
99
  next if redis_sentinels.nil?
76
100
 
77
- raise ArgumentError, "redis_sentinels must be an array; got #{redis_sentinels}" unless
78
- redis_sentinels.is_a?(Array)
101
+ sentinels = Array(redis_sentinels)
79
102
 
80
- next if redis_sentinels.empty?
103
+ next if sentinels.empty?
81
104
 
82
- params[:sentinels] = redis_sentinels.map(&method(:parse_sentinel))
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
- { "host" => matches[1], "port" => matches[2].to_i }
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
- begin
15
- handler.call(exp, method_name, message)
16
- rescue StandardError => exp
17
- AnyCable.logger.error "!!! EXCEPTION HANDLER THREW AN ERROR !!!"
18
- AnyCable.logger.error exp
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
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
4
-
5
3
  module AnyCable
6
4
  # Server for HTTP healthchecks.
7
5
  #
@@ -22,7 +20,7 @@ module AnyCable
22
20
 
23
21
  attr_reader :grpc_server, :port, :path, :server
24
22
 
25
- def initialize(grpc_server, port:, path: "/health", logger: AnyCable.logger)
23
+ def initialize(grpc_server, port:, logger: nil, path: "/health")
26
24
  @grpc_server = grpc_server
27
25
  @port = port
28
26
  @path = path
@@ -50,9 +48,13 @@ module AnyCable
50
48
 
51
49
  private
52
50
 
53
- attr_reader :logger
51
+ def logger
52
+ @logger ||= AnyCable.logger
53
+ end
54
54
 
55
55
  def build_server
56
+ require "webrick"
57
+
56
58
  WEBrick::HTTPServer.new(
57
59
  Port: port,
58
60
  Logger: logger,
@@ -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::Interceptor
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
@@ -48,8 +48,8 @@ module AnyCable
48
48
 
49
49
  unless middleware.is_a?(AnyCable::Middleware)
50
50
  raise ArgumentError,
51
- "AnyCable middleware must be a subclass of AnyCable::Middleware, " \
52
- "got #{middleware} instead"
51
+ "AnyCable middleware must be a subclass of AnyCable::Middleware, " \
52
+ "got #{middleware} instead"
53
53
  end
54
54
 
55
55
  middleware
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module Middlewares
5
+ # Checks that RPC client version is compatible with
6
+ # the current RPC proto version
7
+ class CheckVersion < Middleware
8
+ attr_reader :version
9
+
10
+ def initialize(version)
11
+ @version = version
12
+ end
13
+
14
+ def call(_request, call, _method)
15
+ supported_versions = call.metadata["protov"]&.split(",")
16
+ return yield if supported_versions&.include?(version)
17
+
18
+ raise GRPC::Internal,
19
+ "Incompatible AnyCable RPC client.\nCurrent server version: #{version}.\n" \
20
+ "Client supported versions: #{call.metadata["protov"] || "unknown"}."
21
+ end
22
+ end
23
+ end
24
+ end