anycable 0.5.2 → 0.6.0.rc1

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