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.
@@ -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
@@ -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) ? 'Rails ' : ''}application from #{boot_file}"
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.stop
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
- logger.info "Broadcasting Redis channel: #{config.redis_channel}"
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
- --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
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"
@@ -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
 
@@ -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", logger: AnyCable.logger)
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
- attr_reader :logger
51
+ def logger
52
+ @logger ||= AnyCable.logger
53
+ end
52
54
 
53
55
  def build_server
54
56
  require "webrick"
@@ -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