carrot_rpc 0.2.3.pre

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,77 @@
1
+ # Base RPC Server class. Other Servers should inherit from this.
2
+ class CarrotRpc::RpcServer
3
+ autoload :JSONAPIResources, "carrot_rpc/rpc_server/jsonapi_resources"
4
+
5
+ using CarrotRpc::HashExtensions
6
+
7
+ attr_reader :channel, :server_queue, :logger
8
+ # method_reciver => object that receives the method. can be a class or anything responding to send
9
+
10
+ extend CarrotRpc::ClientServer
11
+
12
+ # Documentation advises not to share a channel connection. Create new channel for each server instance.
13
+ def initialize(config: nil, block: true)
14
+ # create a channel and exchange that both client and server know about
15
+ config ||= CarrotRpc.configuration
16
+ @channel = config.bunny.create_channel
17
+ @logger = config.logger
18
+ @block = block
19
+ @server_queue = @channel.queue(self.class.queue_name)
20
+ @exchange = @channel.default_exchange
21
+ end
22
+
23
+ # start da server!
24
+ # method => object that receives the method. can be a class or anything responding to send
25
+ def start
26
+ # subscribe is like a callback
27
+ @server_queue.subscribe(block: @block) do |_delivery_info, properties, payload|
28
+ logger.debug "Receiving message: #{payload}"
29
+
30
+ request_message = JSON.parse(payload).with_indifferent_access
31
+
32
+ process_request(request_message, properties: properties)
33
+ end
34
+ end
35
+
36
+ def process_request(request_message, properties:)
37
+ result = send(request_message[:method], request_message[:params])
38
+ rescue CarrotRpc::Error => rpc_server_error
39
+ logger.error(rpc_server_error)
40
+
41
+ reply_error rpc_server_error.serialized_message,
42
+ properties: properties,
43
+ request_message: request_message
44
+ else
45
+ reply_result result,
46
+ properties: properties,
47
+ request_message: request_message
48
+ end
49
+
50
+ private
51
+
52
+ def reply(properties:, response_message:)
53
+ @exchange.publish response_message.to_json,
54
+ correlation_id: properties.correlation_id,
55
+ routing_key: properties.reply_to
56
+ end
57
+
58
+ # See http://www.jsonrpc.org/specification#error_object
59
+ def reply_error(error, properties:, request_message:)
60
+ response_message = { error: error, id: request_message[:id], jsonrpc: "2.0" }
61
+
62
+ logger.debug "Publish error: #{error} to #{response_message}"
63
+
64
+ reply properties: properties,
65
+ response_message: response_message
66
+ end
67
+
68
+ # See http://www.jsonrpc.org/specification#response_object
69
+ def reply_result(result, properties:, request_message:)
70
+ response_message = { id: request_message[:id], result: result, jsonrpc: "2.0" }
71
+
72
+ logger.debug "Publishing result: #{result} to #{response_message}"
73
+
74
+ reply properties: properties,
75
+ response_message: response_message
76
+ end
77
+ end
@@ -0,0 +1,41 @@
1
+ # Loads the Rails application so that the servers can use the Rails gems and environment.
2
+ module CarrotRpc::ServerRunner::AutoloadRails
3
+ # Path to the `config/environment.rb`, which is the file that must actually be `require`d to load Rails.
4
+ #
5
+ # @param root [String] path to the root of the Rails app
6
+ # @return [String]
7
+ def self.environment_path(root)
8
+ File.join(root, "config/environment.rb")
9
+ end
10
+
11
+ # Attempts to load Rails app at `root`.
12
+ #
13
+ # @param root [String] path to the root of the Rails app
14
+ # @param logger [Logger] logger to print success to.
15
+ # @return [true]
16
+ # @raise [LoadError] if rails cannot be loaded
17
+ def self.load_root(root, logger:)
18
+ rails_path = environment_path(root)
19
+
20
+ if File.exist?(rails_path)
21
+ logger.info "Rails app found at: #{rails_path}"
22
+ ENV["RACK_ENV"] ||= ENV["RAILS_ENV"] || "development"
23
+ require rails_path
24
+ ::Rails.application.eager_load!
25
+ true
26
+ else
27
+ require rails_path
28
+ end
29
+ end
30
+
31
+ # Loads Rails app at `root` if `CarrotRpc.configuration.autoload_rails`
32
+ #
33
+ # @param (see load_root)
34
+ # @return (see load_root)
35
+ # @raise (see load_root)
36
+ def self.conditionally_load_root(root, logger:)
37
+ if CarrotRpc.configuration.autoload_rails
38
+ load_root(root, logger: logger)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # Constructs a logger based on the {CarrotRpc.configuration} and Rails environment
2
+ module CarrotRpc::ServerRunner::Logger
3
+ # A `Logger` configured based on `CarrotRpc.configuration.logfile` and `CarrotRpc.configuration.loglevel`
4
+ #
5
+ # Fallbacks:
6
+ # * `Rails.logger` if `Rails` is loaded
7
+ # * `Logger` to `STDOUT` if `Rails` is not loaded
8
+ #
9
+ # @return [Logger]
10
+ def self.configured
11
+ logger = from_file
12
+
13
+ if logger.nil?
14
+ logger = if defined?(::Rails)
15
+ CarrotRpc::TaggedLog.new(logger: Rails.logger, tags: ["Carrot RPC"])
16
+ else
17
+ Logger.new(STDOUT)
18
+ end
19
+ end
20
+
21
+ logger.level = CarrotRpc.configuration.loglevel
22
+
23
+ logger
24
+ end
25
+
26
+ def self.from_file
27
+ return nil unless CarrotRpc.configuration.logfile
28
+
29
+ ::Logger.new(CarrotRpc.configuration.logfile)
30
+ end
31
+ end
@@ -0,0 +1,142 @@
1
+ # Pid and pid path for {CarrotRpc::ServerRunner}
2
+ class CarrotRpc::ServerRunner::Pid
3
+ # Attributes
4
+
5
+ # @return [Logger]
6
+ attr_reader :logger
7
+
8
+ # @return [String]
9
+ attr_reader :path
10
+
11
+ # Methods
12
+
13
+ ## Class Methods
14
+
15
+ # The status of the given `pid` number.
16
+ #
17
+ # @param pid [Integer] a 0 or positive PID
18
+ # @return [:dead] if `pid` is `0`
19
+ # @return (see number_error_check_status)
20
+ def self.number_status(pid)
21
+ if pid == 0
22
+ :dead
23
+ else
24
+ number_error_check_status(pid)
25
+ end
26
+ end
27
+
28
+ # The status of the given `non_zero_pid` number.
29
+ #
30
+ # @param non_zero_pid [Integer] a non-zero pid
31
+ # @return [:dead] if `pid` cannot be contacted
32
+ # @return [:not_owned] if interacting with `pid` raises a permission error
33
+ # @return [:running] if `pid` is running
34
+ def self.number_error_check_status(non_zero_pid)
35
+ # sending signal `0` just performs error checking
36
+ Process.kill(0, non_zero_pid)
37
+ rescue Errno::ESRCH
38
+ # Invalid pid
39
+ :dead
40
+ rescue Errno::EPERM
41
+ # no privilege to interact with process
42
+ :not_owned
43
+ else
44
+ :running
45
+ end
46
+
47
+ # Status of the `pid` inside `path`.
48
+ #
49
+ # @return [:exited] if `path` does not exist, which indicates the server never ran or exited cleanly
50
+ # @return [:not_owned] if `path` cannot be read
51
+ # @return (see number_status)
52
+ def self.path_status(path)
53
+ pid = File.read(path).to_i
54
+ rescue Errno::ENOENT
55
+ # File does not exist
56
+ :exited
57
+ rescue Errno::EPERM
58
+ # File cannot be read
59
+ :not_owned
60
+ else
61
+ number_status(pid)
62
+ end
63
+
64
+ ## Initialize
65
+
66
+ # @param path [Path, nil] path to pid path
67
+ def initialize(path:, logger:)
68
+ unless path.nil?
69
+ # daemonization will change CWD so expand relative paths now
70
+ @path = File.expand_path(path)
71
+ end
72
+
73
+ @logger = logger
74
+ end
75
+
76
+ ## Instance Methods
77
+
78
+ # Exits if {#status} indicates server is already running, otherwise deletes {#path}.
79
+ #
80
+ # @return [void]
81
+ def check
82
+ if path?
83
+ case self.class.path_status(path)
84
+ when :running, :not_owned
85
+ logger.warn "A server is already running. Check #{path}"
86
+ exit(1)
87
+ when :dead
88
+ delete
89
+ end
90
+ end
91
+ end
92
+
93
+ # Deletes `path` if it is set
94
+ #
95
+ # @return [void]
96
+ def delete
97
+ if path? && File.exist?(path)
98
+ File.delete(path)
99
+ end
100
+ end
101
+
102
+ # Registers an `at_exit` handler to {#delete}.
103
+ #
104
+ # @return [void]
105
+ def delete_at_exit
106
+ at_exit do
107
+ delete
108
+ end
109
+ end
110
+
111
+ # Keeps trying to write {#path} until it succeeds
112
+ def ensure_written
113
+ if path?
114
+ begin
115
+ write
116
+ rescue Errno::EEXIST
117
+ check
118
+
119
+ retry
120
+ else
121
+ delete_at_exit
122
+ end
123
+ end
124
+ end
125
+
126
+ # Whether {#path} is set.
127
+ #
128
+ # @return [true] if {#path} is not `nil`.
129
+ # @return [false] otherwise
130
+ def path?
131
+ !path.nil?
132
+ end
133
+
134
+ # Write to process id path
135
+ #
136
+ # @return [void]
137
+ def write
138
+ File.open(path, File::CREAT | File::EXCL | File::WRONLY) do |f|
139
+ f.write(Process.pid.to_s)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,21 @@
1
+ # Traps all {the signals NAMES} that should be intercepted by a long-running background process.
2
+ module CarrotRpc::ServerRunner::Signals
3
+ # CONSTANTS
4
+
5
+ # The name of the signals to trap.
6
+ NAMES = %w(HUP INT QUIT TERM).freeze
7
+
8
+ # Traps all {NAMES}.
9
+ #
10
+ # @yield [name] Block to call when the signal is trapped.
11
+ # @yieldparam name [String] the name of the signal that was trapped
12
+ # @yieldreturn [void]
13
+ # @return [void]
14
+ def self.trap
15
+ NAMES.each do |name|
16
+ Kernel.trap(name) do
17
+ yield name
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,156 @@
1
+ require "active_support/core_ext/string/inflections"
2
+
3
+ # Automatically detects, loads, and runs all {CarrotRpc::RpcServer} subclasses under `app/servers` in the project root.
4
+ class CarrotRpc::ServerRunner
5
+ extend ActiveSupport::Autoload
6
+
7
+ autoload :AutoloadRails
8
+ autoload :Logger
9
+ autoload :Pid
10
+ autoload :Signals
11
+
12
+ # Attributes
13
+
14
+ attr_reader :quit, :servers
15
+
16
+ # @return [CarrotRpc::ServerRunner::Pid]
17
+ attr_reader :pid
18
+
19
+ # Methods
20
+
21
+ # Instantiate the ServerRunner.
22
+ def initialize(rails_path: ".", pidfile: nil, runloop_sleep: 0, daemonize: false)
23
+ @runloop_sleep = runloop_sleep
24
+ @daemonize = daemonize
25
+ @servers = []
26
+
27
+ CarrotRpc::ServerRunner::AutoloadRails.conditionally_load_root(rails_path, logger: logger)
28
+ trap_signals
29
+
30
+ @pid = CarrotRpc::ServerRunner::Pid.new(
31
+ path: pidfile,
32
+ logger: logger
33
+ )
34
+ end
35
+
36
+ # Start the servers and the run loop.
37
+ def run!
38
+ pid.check
39
+ daemonize && suppress_output if daemonize?
40
+ pid.ensure_written
41
+
42
+ # Initialize the servers. Set logger.
43
+ run_servers
44
+
45
+ # Sleep for a split second.
46
+ until quit
47
+ sleep @runloop_sleep
48
+ end
49
+ # When runtime gets here, quit signal is received.
50
+ stop_servers
51
+ end
52
+
53
+ # Shutdown all servers defined.
54
+ def stop_servers
55
+ logger.info "#{@siginal_name} signal received!"
56
+ @servers.each do |s|
57
+ logger.info "Shutting Down Server Queue: #{s.server_queue.name}"
58
+ s.channel.close
59
+ end
60
+ # Close the connection once all the other servers are shutdown
61
+ CarrotRpc.configuration.bunny.close
62
+ end
63
+
64
+ # Find and require all servers in the app/servers dir.
65
+ # @param dirs [Array] directories relative to root of host application where RpcServers can be loaded
66
+ # @return [Array] of RpcServers loaded and initialized
67
+ def run_servers(dirs: %w(app servers))
68
+ files = server_files(dirs)
69
+ fail "No servers found!" if files.empty?
70
+
71
+ # Load each server defined in the project dir
72
+ files.each do |file|
73
+ @servers << run_server_file(file)
74
+ end
75
+
76
+ @servers
77
+ end
78
+
79
+ def run_server_file(file)
80
+ require file
81
+ server_klass_name = file.to_s.split("/").last.gsub(".rb", "").camelize
82
+ server_klass = server_klass_name.constantize
83
+
84
+ logger.info "Starting #{server_klass}..."
85
+
86
+ server = server_klass.new(block: false)
87
+ server.start
88
+
89
+ server
90
+ end
91
+
92
+ def server_files(dirs)
93
+ Dir[server_glob(dirs)]
94
+ end
95
+
96
+ def server_glob(dirs)
97
+ regex = %r{\A/.*/#{dirs.join("/")}\z}
98
+ $LOAD_PATH.find { |p|
99
+ p.match(regex)
100
+ } + "/*.rb"
101
+ end
102
+
103
+ # Convenience method to wrap the logger object.
104
+ def logger
105
+ @logger ||= set_logger
106
+ end
107
+
108
+ # attr_reader doesn't allow adding a `?` to the method name, so I think this is a false positive
109
+ # rubocop:disable Style/TrivialAccessors
110
+
111
+ # Attribute to determine when to daemonize the process.
112
+ def daemonize?
113
+ @daemonize
114
+ end
115
+
116
+ # rubocop:enable Style/TrivialAccessors
117
+
118
+ # Background the ruby process.
119
+ def daemonize
120
+ exit if fork
121
+ Process.setsid
122
+ exit if fork
123
+ Dir.chdir "/"
124
+ end
125
+
126
+ # Part of daemonizing process. Prevents application from outputting info to terminal.
127
+ def suppress_output
128
+ $stderr.reopen("/dev/null", "a")
129
+ $stdout.reopen($stderr)
130
+ end
131
+
132
+ # Set a value to signal shutdown.
133
+ def shutdown(name)
134
+ @signal_name = name
135
+ @quit = true
136
+ end
137
+
138
+ # Handle signal events.
139
+ def trap_signals
140
+ CarrotRpc::ServerRunner::Signals.trap do |name|
141
+ # @note can't log from a trap context: since Ruby 2.0 traps don't allow mutexes as it could lead to a dead lock,
142
+ # so `logger.info` here would return "log writing failed. can't be called from trap context"
143
+ shutdown(name)
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ # Determine how to create logger. Config can specify log file.
150
+ def set_logger
151
+ logger = CarrotRpc::ServerRunner::Logger.configured
152
+ CarrotRpc.configuration.logger = logger
153
+
154
+ logger
155
+ end
156
+ end
@@ -0,0 +1,24 @@
1
+ # Wrap the Logger object with convenience methods.
2
+ class CarrotRpc::TaggedLog
3
+ attr_reader :logger, :tags
4
+
5
+ def initialize(logger:, tags:)
6
+ @logger = logger
7
+ @tags = *tags
8
+ end
9
+
10
+ def level
11
+ logger.level
12
+ end
13
+
14
+ def level=(level)
15
+ logger.level = level
16
+ end
17
+
18
+ # Dyanmically define logger methods with a tagged reference. Makes filtering of logs possible.
19
+ %i(debug info warn error fatal unknown).each do |level|
20
+ define_method(level) do |msg|
21
+ logger.tagged(tags) { logger.send(level, msg) }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module CarrotRpc
2
+ VERSION = "0.2.3.pre".freeze
3
+ end
data/lib/carrot_rpc.rb ADDED
@@ -0,0 +1,46 @@
1
+ # gem dependencies
2
+ require "active_support/core_ext/hash/indifferent_access"
3
+ require "active_support/core_ext/hash/except"
4
+ require "active_support/dependencies/autoload"
5
+ require "bunny"
6
+
7
+ # standard library
8
+ require "json"
9
+ require "optparse"
10
+
11
+ # project
12
+ require "carrot_rpc/version"
13
+
14
+ # An opinionated approach to doing Remote Procedure Call (RPC) with RabbitMQ and the bunny gem. CarrotRpc serves as a
15
+ # way to streamline the RPC workflow so developers can focus on the implementation and not the plumbing when working
16
+ # with RabbitMQ.
17
+ module CarrotRpc
18
+ extend ActiveSupport::Autoload
19
+
20
+ autoload :CLI
21
+ autoload :ClientServer
22
+ autoload :Configuration
23
+ autoload :Error
24
+ autoload :HashExtensions
25
+ autoload :RpcClient
26
+ autoload :RpcServer
27
+ autoload :ServerRunner
28
+ autoload :TaggedLog
29
+
30
+ class << self
31
+ attr_writer :configuration
32
+ end
33
+
34
+ def self.configuration
35
+ @configuration ||= Configuration.new
36
+ end
37
+
38
+ # Resets the configuration back to a new instance. Should only be used in testing.
39
+ def self.reset
40
+ @configuration = Configuration.new
41
+ end
42
+
43
+ def self.configure
44
+ yield configuration
45
+ end
46
+ end
data/logs/.gitkeep ADDED
File without changes