anycable 0.5.2 → 0.6.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.
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