anycable 0.5.2 → 0.6.4

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