anycable 0.6.3 → 1.0.0.rc1

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
@@ -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