anycable 0.5.2 → 0.6.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -2
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +19 -60
  5. data/bin/anycable +13 -0
  6. data/bin/anycabled +30 -0
  7. data/lib/anycable.rb +69 -18
  8. data/lib/anycable/broadcast_adapters.rb +33 -0
  9. data/lib/anycable/broadcast_adapters/redis.rb +42 -0
  10. data/lib/anycable/cli.rb +329 -0
  11. data/lib/anycable/config.rb +93 -17
  12. data/lib/anycable/exceptions_handling.rb +37 -0
  13. data/lib/anycable/health_server.rb +52 -31
  14. data/lib/anycable/middleware.rb +19 -0
  15. data/lib/anycable/middleware_chain.rb +58 -0
  16. data/lib/anycable/rpc/rpc_pb.rb +1 -1
  17. data/lib/anycable/rpc/rpc_services_pb.rb +1 -1
  18. data/lib/anycable/rpc_handler.rb +77 -32
  19. data/lib/anycable/server.rb +132 -39
  20. data/lib/anycable/socket.rb +1 -1
  21. data/lib/anycable/version.rb +2 -2
  22. metadata +34 -59
  23. data/.gitignore +0 -40
  24. data/.hound.yml +0 -3
  25. data/.rubocop.yml +0 -71
  26. data/.travis.yml +0 -13
  27. data/Gemfile +0 -8
  28. data/Makefile +0 -5
  29. data/PITCHME.md +0 -139
  30. data/PITCHME.yaml +0 -1
  31. data/Rakefile +0 -8
  32. data/anycable.gemspec +0 -32
  33. data/assets/Memory3.png +0 -0
  34. data/assets/Memory5.png +0 -0
  35. data/assets/RTT3.png +0 -0
  36. data/assets/RTT5.png +0 -0
  37. data/assets/Scheme1.png +0 -0
  38. data/assets/Scheme2.png +0 -0
  39. data/assets/cpu_chart.gif +0 -0
  40. data/assets/cpu_chart2.gif +0 -0
  41. data/assets/evlms.png +0 -0
  42. data/benchmarks/.gitignore +0 -1
  43. data/benchmarks/2017-02-12.md +0 -308
  44. data/benchmarks/2018-03-04.md +0 -192
  45. data/benchmarks/2018-05-27-rpc-bench.md +0 -57
  46. data/benchmarks/HowTo.md +0 -23
  47. data/benchmarks/ansible.cfg +0 -9
  48. data/benchmarks/benchmark.yml +0 -67
  49. data/benchmarks/hosts +0 -5
  50. data/benchmarks/servers.yml +0 -36
  51. data/circle.yml +0 -8
  52. data/etc/bug_report_template.rb +0 -76
  53. data/lib/anycable/handler/exceptions_handling.rb +0 -43
  54. data/lib/anycable/pubsub.rb +0 -26
  55. data/protos/rpc.proto +0 -55
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnyCable
4
+ module BroadcastAdapters # :nodoc:
5
+ module_function
6
+
7
+ # rubocop: disable Metrics/AbcSize, Metrics/MethodLength
8
+ def lookup_adapter(args)
9
+ adapter, options = Array(args)
10
+ path_to_adapter = "anycable/broadcast_adapters/#{adapter}"
11
+ adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join
12
+
13
+ unless BroadcastAdapters.const_defined?(adapter_class_name, false)
14
+ begin
15
+ require path_to_adapter
16
+ rescue LoadError => e
17
+ # We couldn't require the adapter itself.
18
+ if e.path == path_to_adapter
19
+ raise e.class, "Couldn't load the '#{adapter}' broadcast adapter for AnyCable",
20
+ e.backtrace
21
+ # Bubbled up from the adapter require.
22
+ else
23
+ raise e.class, "Error loading the '#{adapter}' broadcast adapter for AnyCable",
24
+ e.backtrace
25
+ end
26
+ end
27
+ end
28
+
29
+ BroadcastAdapters.const_get(adapter_class_name, false).new(**(options || {}))
30
+ end
31
+ # rubocop: enable Metrics/AbcSize, Metrics/MethodLength
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "redis", ">= 3"
4
+
5
+ require "redis"
6
+ require "json"
7
+
8
+ module AnyCable
9
+ module BroadcastAdapters
10
+ # Redis adapter for broadcasting.
11
+ #
12
+ # Example:
13
+ #
14
+ # AnyCable.broadast_adapter = :redis
15
+ #
16
+ # It uses Redis configuration from global AnyCable config
17
+ # by default.
18
+ #
19
+ # You can override these params:
20
+ #
21
+ # AnyCable.broadcast_adapter = :redis, url: "redis://my_redis", channel: "_any_cable_"
22
+ class Redis
23
+ attr_reader :redis_conn, :channel
24
+
25
+ def initialize(
26
+ channel: AnyCable.config.redis_channel,
27
+ **options
28
+ )
29
+ options = AnyCable.config.to_redis_params.merge(options)
30
+ @redis_conn = ::Redis.new(options)
31
+ @channel = channel
32
+ end
33
+
34
+ def broadcast(stream, payload)
35
+ redis_conn.publish(
36
+ channel,
37
+ { stream: stream, data: payload }.to_json
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require "anycable"
6
+
7
+ $stdout.sync = true
8
+
9
+ module AnyCable
10
+ # Command-line interface for running AnyCable gRPC server
11
+ # rubocop:disable Metrics/ClassLength
12
+ class CLI
13
+ # (not-so-big) List of common boot files for
14
+ # different applications
15
+ APP_CANDIDATES = %w[
16
+ ./config/anycable.rb
17
+ ./config/environment.rb
18
+ ].freeze
19
+
20
+ # Wait for external process termination (s)
21
+ WAIT_PROCESS = 2
22
+
23
+ attr_reader :server, :health_server
24
+
25
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
26
+ def run(args = {})
27
+ @at_stop = []
28
+
29
+ extra_options = parse_cli_options!(args)
30
+
31
+ # Boot app first, 'cause it might change
32
+ # configuration, loggin settings, etc.
33
+ boot_app!
34
+
35
+ parse_gem_options!(extra_options)
36
+
37
+ configure_server!
38
+
39
+ logger.info "Starting AnyCable gRPC server (pid: #{Process.pid})"
40
+
41
+ print_versions!
42
+
43
+ logger.info "Serving #{defined?(::Rails) ? 'Rails ' : ''}application from #{boot_file}"
44
+
45
+ verify_connection_factory!
46
+
47
+ log_grpc! if config.log_grpc
48
+
49
+ log_errors!
50
+
51
+ @server = AnyCable::Server.new(
52
+ host: config.rpc_host,
53
+ **config.to_grpc_params,
54
+ interceptors: AnyCable.middleware.to_a
55
+ )
56
+
57
+ # Make sure middlewares are not adding after server has started
58
+ AnyCable.middleware.freeze
59
+
60
+ start_health_server! if config.http_health_port_provided?
61
+ start_pubsub!
62
+
63
+ server.start
64
+
65
+ run_custom_server_command! unless server_command.nil?
66
+
67
+ begin
68
+ wait_till_terminated
69
+ rescue Interrupt => e
70
+ logger.info "Stopping... #{e.message}"
71
+
72
+ shutdown
73
+
74
+ logger.info "Stopped. Good-bye!"
75
+ exit(0)
76
+ end
77
+ end
78
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
79
+
80
+ def shutdown
81
+ at_stop.each(&:call)
82
+ server.stop
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :boot_file, :server_command
88
+
89
+ def config
90
+ AnyCable.config
91
+ end
92
+
93
+ def logger
94
+ AnyCable.logger
95
+ end
96
+
97
+ def at_stop
98
+ if block_given?
99
+ @at_stop << Proc.new
100
+ else
101
+ @at_stop
102
+ end
103
+ end
104
+
105
+ def wait_till_terminated
106
+ self_read = setup_signals
107
+
108
+ while readable_io = IO.select([self_read]) # rubocop:disable Lint/AssignmentInCondition
109
+ signal = readable_io.first[0].gets.strip
110
+ raise Interrupt, "SIG#{signal} received"
111
+ end
112
+ end
113
+
114
+ def setup_signals
115
+ self_read, self_write = IO.pipe
116
+
117
+ %w[INT TERM].each do |signal|
118
+ trap signal do
119
+ self_write.puts signal
120
+ end
121
+ end
122
+
123
+ self_read
124
+ end
125
+
126
+ def print_versions!
127
+ logger.info "AnyCable version: #{AnyCable::VERSION}"
128
+ logger.info "gRPC version: #{GRPC::VERSION}"
129
+ end
130
+
131
+ # rubocop:disable Metrics/MethodLength
132
+ def boot_app!
133
+ @boot_file ||= try_detect_app
134
+
135
+ if boot_file.nil?
136
+ $stdout.puts(
137
+ "Couldn't find an application to load. " \
138
+ "Please specify the explicit path via -r option, e.g:" \
139
+ " anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
140
+ )
141
+ exit(1)
142
+ end
143
+
144
+ begin
145
+ require boot_file
146
+ rescue LoadError => e
147
+ $stdout.puts(
148
+ "Failed to load application: #{e.message}. " \
149
+ "Please specify the explicit path via -r option, e.g:" \
150
+ " anycable -r ./config/boot.rb or anycable -r /app/config/load_me.rb"
151
+ )
152
+ exit(1)
153
+ end
154
+ end
155
+ # rubocop:enable Metrics/MethodLength
156
+
157
+ def try_detect_app
158
+ APP_CANDIDATES.detect { |path| File.exist?(path) }
159
+ end
160
+
161
+ def configure_server!
162
+ AnyCable.server_callbacks.each(&:call)
163
+ end
164
+
165
+ def start_health_server!
166
+ @health_server = AnyCable::HealthServer.new(
167
+ server,
168
+ **config.to_http_health_params
169
+ )
170
+ health_server.start
171
+
172
+ at_stop { health_server.stop }
173
+ end
174
+
175
+ def start_pubsub!
176
+ logger.info "Broadcasting Redis channel: #{config.redis_channel}"
177
+ end
178
+
179
+ # rubocop: disable Metrics/MethodLength, Metrics/AbcSize
180
+ def run_custom_server_command!
181
+ pid = nil
182
+ stopped = false
183
+ command_thread = Thread.new do
184
+ pid = Process.spawn(server_command)
185
+ logger.info "Started command: #{server_command} (pid: #{pid})"
186
+
187
+ Process.wait pid
188
+ pid = nil
189
+ raise Interrupt, "Server command exit unexpectedly" unless stopped
190
+ end
191
+
192
+ command_thread.abort_on_exception = true
193
+
194
+ at_stop do
195
+ stopped = true
196
+ next if pid.nil?
197
+
198
+ Process.kill("SIGTERM", pid)
199
+
200
+ logger.info "Wait till process #{pid} stop..."
201
+
202
+ tick = 0
203
+
204
+ loop do
205
+ tick += 0.2
206
+ break if tick > WAIT_PROCESS
207
+
208
+ if pid.nil?
209
+ logger.info "Process #{pid} stopped."
210
+ break
211
+ end
212
+ end
213
+ end
214
+ end
215
+ # rubocop: enable Metrics/MethodLength, Metrics/AbcSize
216
+
217
+ def log_grpc!
218
+ ::GRPC.define_singleton_method(:logger) { AnyCable.logger }
219
+ end
220
+
221
+ # Add default exceptions handler: print error message to log
222
+ def log_errors!
223
+ if AnyCable.config.debug?
224
+ # Print error with backtrace in debug mode
225
+ AnyCable.capture_exception do |e|
226
+ AnyCable.logger.error("#{e.message}:\n#{e.backtrace.take(20).join("\n")}")
227
+ end
228
+ else
229
+ AnyCable.capture_exception { |e| AnyCable.logger.error(e.message) }
230
+ end
231
+ end
232
+
233
+ def verify_connection_factory!
234
+ return if AnyCable.connection_factory
235
+
236
+ logger.error "AnyCable connection factory must be configured. " \
237
+ "Make sure you've required a gem (e.g. `anycable-rails`) or " \
238
+ "configured `AnyCable.connection_factory` yourself"
239
+ exit(1)
240
+ end
241
+
242
+ def parse_gem_options!(args)
243
+ config.parse_options!(args)
244
+ rescue OptionParser::InvalidOption => e
245
+ $stdout.puts e.message
246
+ $stdout.puts "Run anycable -h to see available options"
247
+ exit(1)
248
+ end
249
+
250
+ # rubocop:disable Metrics/MethodLength
251
+ def parse_cli_options!(args)
252
+ unknown_opts = []
253
+
254
+ parser = build_cli_parser
255
+
256
+ begin
257
+ parser.parse!(args)
258
+ rescue OptionParser::InvalidOption => e
259
+ unknown_opts << e.args[0]
260
+ unless args.size.zero?
261
+ unknown_opts << args.shift unless args.first.start_with?("-")
262
+ retry
263
+ end
264
+ end
265
+
266
+ unknown_opts
267
+ end
268
+
269
+ def build_cli_parser
270
+ OptionParser.new do |o|
271
+ o.on "-v", "--version", "Print version and exit" do |_arg|
272
+ $stdout.puts "AnyCable v#{AnyCable::VERSION}"
273
+ exit(0)
274
+ end
275
+
276
+ o.on "-r", "--require [PATH|DIR]", "Location of application file to require" do |arg|
277
+ @boot_file = arg
278
+ end
279
+
280
+ o.on "--server-command VALUE", "Command to run WebSocket server" do |arg|
281
+ @server_command = arg
282
+ end
283
+
284
+ o.on_tail "-h", "--help", "Show help" do
285
+ $stdout.puts usage
286
+ exit(0)
287
+ end
288
+ end
289
+ end
290
+ # rubocop:enable Metrics/MethodLength
291
+
292
+ def usage
293
+ <<~HELP
294
+ anycable: run AnyCable gRPC server (https://anycable.io)
295
+
296
+ VERSION
297
+ anycable/#{AnyCable::VERSION}
298
+
299
+ USAGE
300
+ $ anycable [options]
301
+
302
+ BASIC OPTIONS
303
+ -r, --require=path Location of application file to require, default: "config/environment.rb"
304
+ --server-command=command Command to run WebSocket server
305
+ --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
309
+ --log-level=level Logging level, default: "info"
310
+ --log-file=path Path to log file, default: <none> (log to STDOUT)
311
+ --log-grpc Enable gRPC logging (disabled by default)
312
+ --debug Turn on verbose logging ("debug" level and gRPC logging on)
313
+ -v, --version Print version and exit
314
+ -h, --help Show this help
315
+
316
+ HTTP HEALTH CHECKER OPTIONS
317
+ --http-health-port=port Port to run HTTP health server on, default: <none> (disabled)
318
+ --http-health-path=path Endpoint to server health cheks, default: "/health"
319
+
320
+ GRPC OPTIONS
321
+ --rpc-pool-size=size gRPC workers pool size, default: 30
322
+ --rpc-max-waiting-requests=num Max waiting requests queue size, default: 20
323
+ --rpc-poll-period=seconds Poll period (sec), default: 1
324
+ --rpc-pool-keep-alive=seconds Keep-alive polling interval (sec), default: 1
325
+ HELP
326
+ end
327
+ end
328
+ # rubocop:enable Metrics/ClassLength
329
+ end
@@ -1,32 +1,108 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "anyway_config"
4
+ require "grpc"
4
5
 
5
- module Anycable
6
- # Anycable configuration.
6
+ module AnyCable
7
+ # AnyCable configuration.
7
8
  class Config < Anyway::Config
8
9
  config_name :anycable
9
10
 
10
- attr_config rpc_host: "localhost:50051",
11
- redis_url: "redis://localhost:6379/5",
12
- redis_sentinels: [],
13
- redis_channel: "__anycable__",
14
- log_file: nil,
15
- log_level: :info,
16
- log_grpc: false,
17
- debug: false, # Shortcut to enable GRPC logging and debug level
18
- http_health_port: nil
11
+ DefaultHostWrapper = Class.new(String)
19
12
 
20
- def initialize(*)
21
- super
22
- # Set log params if debug is true
23
- return unless debug
24
- self.log_level = :debug
25
- self.log_grpc = true
13
+ attr_config(
14
+ ### gRPC options
15
+ rpc_host: DefaultHostWrapper.new("[::]:50051"),
16
+ # For defaults see https://github.com/grpc/grpc/blob/51f0d35509bcdaba572d422c4f856208162022de/src/ruby/lib/grpc/generic/rpc_server.rb#L186-L216
17
+ rpc_pool_size: GRPC::RpcServer::DEFAULT_POOL_SIZE,
18
+ rpc_max_waiting_requests: GRPC::RpcServer::DEFAULT_MAX_WAITING_REQUESTS,
19
+ rpc_poll_period: GRPC::RpcServer::DEFAULT_POLL_PERIOD,
20
+ rpc_pool_keep_alive: GRPC::Pool::DEFAULT_KEEP_ALIVE,
21
+ # See https://github.com/grpc/grpc/blob/f526602bff029b8db50a8d57134d72da33d8a752/include/grpc/impl/codegen/grpc_types.h#L292-L315
22
+ rpc_server_args: {},
23
+
24
+ ### Redis options
25
+ redis_url: ENV.fetch("REDIS_URL", "redis://localhost:6379/5"),
26
+ redis_sentinels: nil,
27
+ redis_channel: "__anycable__",
28
+
29
+ ### Logging options
30
+ log_file: nil,
31
+ log_level: :info,
32
+ log_grpc: false,
33
+ debug: false, # Shortcut to enable GRPC logging and debug level
34
+
35
+ ### Health check options
36
+ http_health_port: nil,
37
+ http_health_path: "/health"
38
+ )
39
+
40
+ ignore_options :rpc_server_args
41
+ flag_options :log_grpc, :debug
42
+
43
+ def log_level
44
+ debug ? :debug : @log_level
45
+ end
46
+
47
+ def log_grpc
48
+ debug || @log_grpc
26
49
  end
27
50
 
51
+ def debug
52
+ @debug != false
53
+ end
54
+
55
+ alias debug? debug
56
+
28
57
  def http_health_port_provided?
29
58
  !http_health_port.nil? && http_health_port != ""
30
59
  end
60
+
61
+ # Build gRPC server parameters
62
+ def to_grpc_params
63
+ {
64
+ pool_size: rpc_pool_size,
65
+ max_waiting_requests: rpc_max_waiting_requests,
66
+ poll_period: rpc_poll_period,
67
+ pool_keep_alive: rpc_pool_keep_alive,
68
+ server_args: rpc_server_args
69
+ }
70
+ end
71
+
72
+ # Build Redis parameters
73
+ def to_redis_params
74
+ { url: redis_url }.tap do |params|
75
+ next if redis_sentinels.nil?
76
+
77
+ raise ArgumentError, "redis_sentinels must be an array; got #{redis_sentinels}" unless
78
+ redis_sentinels.is_a?(Array)
79
+
80
+ next if redis_sentinels.empty?
81
+
82
+ params[:sentinels] = redis_sentinels.map(&method(:parse_sentinel))
83
+ end
84
+ end
85
+
86
+ # Build HTTP health server parameters
87
+ def to_http_health_params
88
+ {
89
+ port: http_health_port,
90
+ path: http_health_path
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ SENTINEL_RXP = /^([\w\-_]*)\:(\d+)$/.freeze
97
+
98
+ def parse_sentinel(sentinel)
99
+ return sentinel if sentinel.is_a?(Hash)
100
+
101
+ matches = sentinel.match(SENTINEL_RXP)
102
+
103
+ raise ArgumentError, "Invalid Sentinel value: #{sentinel}" if matches.nil?
104
+
105
+ { "host" => matches[1], "port" => matches[2].to_i }
106
+ end
31
107
  end
32
108
  end