brown 2.2.2 → 2.2.2.25.g85ddf08

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ # AMQP support for Brown agents.
2
+ #
3
+ # Including this module in your agent provides support for publishing and
4
+ # receiving AMQP messages from an AMQP broker, such as RabbitMQ.
5
+ #
6
+ # The methods in this module itself aren't particularly interesting to end-users;
7
+ # the good stuff is in {Brown::Agent::AMQP::ClassMethods}.
8
+ #
9
+ module Brown::Agent::AMQP
10
+ private
11
+
12
+ def self.included(mod)
13
+ mod.url :AMQP_URL
14
+ mod.prepend(Brown::Agent::AMQP::Initializer)
15
+ mod.extend(Brown::Agent::AMQP::ClassMethods)
16
+ end
17
+ end
18
+
19
+ require_relative "amqp/initializer"
20
+ require_relative "amqp/class_methods"
@@ -0,0 +1,147 @@
1
+ require 'logger'
2
+ require 'securerandom'
3
+
4
+ # Class-level AMQP support for Brown agents.
5
+ #
6
+ # These methods are intended to be applied to a `Brown::Agent` subclass, so you
7
+ # can use them to define new AMQP listeners and publishers in your agent classes.
8
+ # You should not attempt to extend your classes directly with this module; the
9
+ # {Brown::Agent::AMQP} module should handle that for you automatically.
10
+ #
11
+ module Brown::Agent::AMQP::ClassMethods
12
+ attr_reader :amqp_publishers, :amqp_listeners
13
+
14
+ # Declare an AMQP publisher, and create an AMQP exchange to publish to.
15
+ #
16
+ # On the assumption that you already know [how exchanges
17
+ # work](http://www.rabbitmq.com/tutorials/amqp-concepts.html), lets
18
+ # just dive right in.
19
+ #
20
+ # This method creates an accessor method on your agent class, named after the
21
+ # symbol you pass in as `name`, which returns an instance of
22
+ # `Brown::Agent::AMQPPublisher`. This object, in turn, defines an
23
+ # AMQP exchange when it is created, and has a
24
+ # `publish` method on it (see {Brown::Agent::AMQPPublisher#publish}) which
25
+ # sends arbitrary messages to the exchange.
26
+ #
27
+ # @param name [Symbol] the name of the accessor method to call when
28
+ # you want to reference this publisher in your agent code.
29
+ #
30
+ # @param publisher_opts [Hash] options which are passed to
31
+ # {Brown::Agent::AMQPPublisher#initialize}.
32
+ #
33
+ # This method is a thin shim around {Brown::Agent::AMQPPublisher#initialize};
34
+ # you should read that method's documentation for details of what
35
+ # constitutes valid `publisher_opts`, and also what exceptions can be
36
+ # raised.
37
+ #
38
+ # @see Brown::Agent::AMQPPublisher#initialize
39
+ # @see Brown::Agent::AMQPPublisher#publish
40
+ #
41
+ def amqp_publisher(name, publisher_opts = {})
42
+ @amqp_publishers ||= []
43
+ @amqp_publishers << { name: name, opts: publisher_opts }
44
+ end
45
+
46
+ # Declare a stimulus to listen for messages from an AMQP broker.
47
+ #
48
+ # When the agent is started, we will setup a queue, bound to the exchange
49
+ # specified by the `exchange_name` argument, and then proceed to hoover up
50
+ # all the messages we can.
51
+ #
52
+ # The name of the queue that is created by default is derived from the
53
+ # agent class name and the exchange name being bound to. This allows
54
+ # for multiple instances of the same agent, running in separate
55
+ # processes or machines, to share the same queue of messages to
56
+ # process, for throughput or redundancy reasons.
57
+ #
58
+ # @param exchange_name [#to_s, Array<#to_s>] the name of the exchange
59
+ # to bind to. You can also specify an array of exchange names, to
60
+ # have all of them put their messages into the one queue. This can
61
+ # be dangerous, because you need to make sure that your message
62
+ # handler can process the different types of messages that might be
63
+ # sent to the different exchangs.
64
+ #
65
+ # @param queue_name [#to_s] the name of the queue to create, if you
66
+ # don't want to use the class-derived default for some reason.
67
+ #
68
+ # @param concurrency [Integer] how many messages to process in parallel.
69
+ # The default, `1`, means that a message will need to be acknowledged
70
+ # (by calling `message.ack`) in your worker `blk` before the broker
71
+ # will consider sending another.
72
+ #
73
+ # If your agent is capable of processing more than one message in
74
+ # parallel (because the agent spends a lot of its time waiting for
75
+ # databases or HTTP requests, for example, or perhaps you're running
76
+ # your agents in a Ruby VM which has no GIL) you should increase
77
+ # this value to improve performance. Alternately, if you want/need
78
+ # to batch processing (say, you insert 100 records into a database
79
+ # in a single query) you'll need to increase this to get multiple
80
+ # records at once.
81
+ #
82
+ # Setting this to `0` is only for the adventurous. It tells the
83
+ # broker to send your agent messages as fast as it can. You still
84
+ # need to acknowledge the messages as you finish processing them
85
+ # (otherwise the broker will not consider them "delivered") but you
86
+ # will always be sent more messages if there are more to send, even
87
+ # if you never acknowledge any of them. This *can* get you into an
88
+ # awful lot of trouble if you're not careful, so don't do it just
89
+ # because you can.
90
+ #
91
+ # @param autoparse [Boolean] if your messages are all of a single
92
+ # content type, and the first thing you do on receiving a message is to
93
+ # call `JSON.parse(msg.payload)`, then you can turn on `autoparse`, and
94
+ # *as long as the message content-type is set correctly*, your messages
95
+ # will be parsed into native data structures before being turned into the
96
+ # `payload`. This also enables automatic deserialization of object
97
+ # messages.
98
+ #
99
+ # @param allowed_classes [Array<Class>] a list of classes which are
100
+ # permitted to be instantiated when a message's `content_type` attribute
101
+ # indicates it is a serialized object. If a message is received that
102
+ # contains a class not in the list (even if that object is embedded inside
103
+ # the message itself), the message will be rejected.
104
+ #
105
+ # @param routing_key [#to_s] if specified, then the queue that is created
106
+ # will be bound to the exchange with a defined routing key.
107
+ #
108
+ # @param predeclared [Boolean] indicates that the necessary queues and
109
+ # bindings are already in place, and thus no declarations should be
110
+ # made to the AMQP broker.
111
+ #
112
+ # @param blk [Proc] is called every time a message is received from
113
+ # the queue, and an instance of {Brown::Agent::AMQPMessage} will
114
+ # be passed as the sole argument.
115
+ #
116
+ # @yieldparam message [Brown::Agent::AMQPMessage] is passed to `blk`
117
+ # each time a message is received from the queue.
118
+ #
119
+ def amqp_listener(exchange_name = "",
120
+ queue_name: nil,
121
+ concurrency: 1,
122
+ autoparse: false,
123
+ allowed_classes: nil,
124
+ routing_key: nil,
125
+ predeclared: false,
126
+ &blk
127
+ )
128
+ exchange_list = (Array === exchange_name ? exchange_name : [exchange_name]).map(&:to_s)
129
+
130
+ if queue_name.nil?
131
+ munged_exchange_list = exchange_list.map { |n| n.to_s == "" ? "" : "-#{n.to_s}" }.join
132
+ queue_name = self.name.to_s + munged_exchange_list + (routing_key ? ":#{routing_key}" : "")
133
+ end
134
+
135
+ @amqp_listeners ||= []
136
+ @amqp_listeners << {
137
+ exchange_list: exchange_list,
138
+ queue_name: queue_name,
139
+ concurrency: concurrency,
140
+ autoparse: autoparse,
141
+ allowed_classes: allowed_classes,
142
+ routing_key: routing_key,
143
+ callback: blk,
144
+ predeclared: predeclared,
145
+ }
146
+ end
147
+ end
@@ -0,0 +1,144 @@
1
+ require "securerandom"
2
+ require "json"
3
+ require "yaml"
4
+
5
+ # Methods that have to be prepended in order to work properly.
6
+ #
7
+ module Brown::Agent::AMQP::Initializer
8
+ def initialize(*_)
9
+ begin
10
+ super
11
+ rescue ArgumentError => ex
12
+ if ex.message =~ /wrong number of arguments.*expected 0/
13
+ super()
14
+ else
15
+ raise
16
+ end
17
+ end
18
+
19
+ initialize_publishers
20
+ end
21
+
22
+ def run
23
+ initialize_listeners
24
+
25
+ super
26
+ end
27
+
28
+ def shutdown
29
+ amqp_session.close
30
+
31
+ super
32
+ end
33
+
34
+ private
35
+
36
+ def amqp_session
37
+ @amqp_session ||= begin
38
+ logger.debug(logloc) { "Initializing AMQP session" }
39
+ Bunny.new(config.amqp_url, recover_from_connection_close: true, logger: config.logger).tap do |session|
40
+ session.on_blocked { |blocked| logger.warn(logloc) { "AMQP connection has become blocked: #{blocked.reason}" } }
41
+ session.on_unblocked { logger.info(logloc) { "AMQP connection has unblocked" } }
42
+ session.start
43
+ end
44
+ end
45
+ end
46
+
47
+ def initialize_publishers
48
+ (self.class.amqp_publishers || []).each do |publisher|
49
+ logger.debug(logloc) { "Initializing AMQP publisher #{publisher}" }
50
+ opts = { exchange_name: publisher[:name] }.merge(publisher[:opts])
51
+
52
+ amqp_publisher = Brown::Agent::AMQPPublisher.new(amqp_session: amqp_session, **opts)
53
+
54
+ define_singleton_method(publisher[:name]) { amqp_publisher }
55
+ end
56
+ end
57
+
58
+ def initialize_listeners
59
+ (self.class.amqp_listeners || []).each do |listener|
60
+ logger.debug(logloc) { "Initializing AMQP listener #{listener}" }
61
+ worker_method = "amqp_listener_worker_#{SecureRandom.uuid}".to_sym
62
+ define_singleton_method(worker_method, listener[:callback])
63
+
64
+ @stimuli ||= []
65
+ @stimuli << {
66
+ name: "amqp_listener_#{listener[:exchange_list].join("_").gsub(/[^A-Za-z0-9_]/, '_').gsub(/__+/, "_")}",
67
+ method: method(worker_method),
68
+ stimuli_proc: proc do |worker|
69
+ consumer = queue(listener).subscribe(manual_ack: true) do |di, prop, payload|
70
+ if listener[:autoparse]
71
+ logger.debug(logloc) { "Attempting to autoparse against Content-Type: #{prop.content_type.inspect}" }
72
+ case prop.content_type
73
+ when "application/json"
74
+ logger.debug(logloc) { "Parsing as JSON" }
75
+ payload = JSON.parse(payload)
76
+ when "application/x.yaml"
77
+ logger.debug(logloc) { "Parsing as YAML" }
78
+ payload = YAML.load(payload)
79
+ when "application/vnd.brown.object.v1"
80
+ logger.debug(logloc) { "Parsing as Brown object, allowed classes: #{listener[:allowed_classes]}" }
81
+ begin
82
+ payload = YAML.safe_load(payload, listener[:allowed_classes])
83
+ rescue Psych::DisallowedClass => ex
84
+ logger.error(logloc) { "message rejected: #{ex.message}" }
85
+ di.channel.nack(di.delivery_tag, false, false)
86
+ next
87
+ end
88
+ end
89
+ end
90
+
91
+ worker.call Brown::Agent::AMQPMessage.new(di, prop, payload)
92
+ end
93
+
94
+ while consumer&.channel&.status == :open do
95
+ logger.debug(logloc) { "stimuli_proc for #{listener[:queue_name]} having a snooze" }
96
+ sleep
97
+ end
98
+ end
99
+ }
100
+ end
101
+ end
102
+
103
+ def queue(listener)
104
+ @queue_cache ||= {}
105
+ @queue_cache[listener] ||= begin
106
+ bind_queue(
107
+ queue_name: listener[:queue_name],
108
+ exchange_list: listener[:exchange_list].map(&:to_s),
109
+ concurrency: listener[:concurrency],
110
+ routing_key: listener[:routing_key],
111
+ predeclared: listener[:predeclared],
112
+ )
113
+ rescue StandardError => ex
114
+ log_exception(ex) { "Unknown error while binding queue #{listener[:queue_name].inspect} to exchange list #{listener[:exchange_list].inspect}" }
115
+ sleep 5
116
+ retry
117
+ end
118
+ end
119
+
120
+ def bind_queue(queue_name:, exchange_list:, concurrency:, routing_key: nil, predeclared: false)
121
+ ch = amqp_session.create_channel
122
+ ch.prefetch(concurrency)
123
+
124
+ ch.queue(queue_name, durable: true, no_declare: predeclared).tap do |q|
125
+ next if predeclared
126
+ exchange_list.each do |exchange_name|
127
+ if exchange_name != ""
128
+ begin
129
+ q.bind(exchange_name, routing_key: routing_key)
130
+ rescue Bunny::NotFound => ex
131
+ logger.error { "bind failed: #{ex.message}" }
132
+ sleep 5
133
+ return bind_queue(
134
+ queue_name: queue_name,
135
+ exchange_list: exchange_list,
136
+ concurrency: concurrency,
137
+ routing_key: routing_key,
138
+ )
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -7,11 +7,14 @@
7
7
  # "delivered".
8
8
  #
9
9
  class Brown::Agent::AMQPMessage
10
- # The raw body of the message. No translation is done on what was sent,
11
- # so any serialisation that might have been applied to the message will
12
- # have to be undone manually.
10
+ # The body of the message.
13
11
  #
14
- # @return [String]
12
+ # Depending on the listener configuration, this may either be a string
13
+ # representing the raw message, or else a deserialized object of some
14
+ # sort. See the documentation for the amqp_listener method's `autoparse`
15
+ # and `allowed_classes` parameters for more details.
16
+ #
17
+ # @return [Object]
15
18
  #
16
19
  attr_reader :payload
17
20
 
@@ -39,4 +42,23 @@ class Brown::Agent::AMQPMessage
39
42
  def ack
40
43
  @delivery_info.channel.ack(@delivery_info.delivery_tag)
41
44
  end
45
+
46
+ # Decline to handle this message, and requeue for later
47
+ #
48
+ # If, for some reason, the agent can't successfully process this message
49
+ # right *now*, but you'd like it to be processed again later, then you
50
+ # can ask for it to be requeued using this handy-dandy method.
51
+ #
52
+ def requeue
53
+ @delivery_info.channel.nack(@delivery_info.delivery_tag, false, true)
54
+ end
55
+
56
+ # Decline to handle this message, now and forever
57
+ #
58
+ # If you want to dead-letter (or just disappear) a message, call this
59
+ # method, and it will cease to exist.
60
+ #
61
+ def reject
62
+ @delivery_info.channel.nack(@delivery_info.delivery_tag, false, false)
63
+ end
42
64
  end
@@ -1,8 +1,13 @@
1
1
  require 'bunny'
2
+ require 'json'
3
+ require 'service_skeleton/logging_helpers'
4
+ require 'yaml'
2
5
 
3
6
  # Publish messages to an AMQP exchange.
4
7
  #
5
8
  class Brown::Agent::AMQPPublisher
9
+ include ServiceSkeleton::LoggingHelpers
10
+
6
11
  #:nodoc:
7
12
  # Sentinel to detect that we've been sent the "default" value,
8
13
  # since `nil` can, sometimes, be a valid value.
@@ -34,21 +39,14 @@ class Brown::Agent::AMQPPublisher
34
39
  # Setup an exchange in the AMQP broker, and allow the publishing of
35
40
  # messages to that exchange.
36
41
  #
37
- # @param amqp_url [#to_s] the AMQP broker to connect to, specified as a
38
- # URL. The scheme must be `amqp`. Username and password should be
39
- # given in the standard fashion (`amqp://<user>:<pass>@<host>`).
40
- #
41
- # The path portion of AMQP URLs is totes spesh; if you want to
42
- # connect to the default vhost (`/`) you either need to specify *no*
43
- # trailing slash (ie `amqp://hostname`) or percent-encode the `/`
44
- # vhost name (ie `amqp://hostname/%2F`). Yes, this drives me nuts,
45
- # too.
42
+ # @param amqp_session [Bunny::Session] an active session with the AMQP
43
+ # server. Typically this will be the agent's `@amqp_session` variable.
46
44
  #
47
45
  # @param exchange_type [Symbol] the type of exchange to create or publish
48
46
  # to. By default, the exchange is created as a *direct* exchange; this
49
47
  # routes messages to their destination queue(s) based on the
50
48
  # `routing_key` (set per-publisher or per-queue). Other valid values
51
- # for this option are `:direct`, `:topic`, and `:headers`.
49
+ # for this option are `:fanout`, `:topic`, and `:headers`.
52
50
  #
53
51
  # @param exchange_name [#to_s] the name of the exchange to create or
54
52
  # publish to. If not specified, then the "default" exchange is used,
@@ -88,7 +86,7 @@ class Brown::Agent::AMQPPublisher
88
86
  # create the exchange fails for some reason (such as the exchange
89
87
  # already existing with a different configuration).
90
88
  #
91
- def initialize(amqp_url: "amqp://localhost",
89
+ def initialize(amqp_session:,
92
90
  exchange_type: :direct,
93
91
  exchange_name: "",
94
92
  routing_key: nil,
@@ -96,23 +94,11 @@ class Brown::Agent::AMQPPublisher
96
94
  logger: Logger.new("/dev/null"),
97
95
  **amqp_opts
98
96
  )
99
- begin
100
- @amqp_session = Bunny.new(amqp_url, logger: logger)
101
- @amqp_session.start
102
- rescue Bunny::TCPConnectionFailed
103
- raise BrokerError,
104
- "Failed to connect to #{amqp_url}"
105
- rescue Bunny::PossibleAuthenticationFailureError
106
- raise BrokerError,
107
- "Authentication failed for #{amqp_url}"
108
- rescue StandardError => ex
109
- raise Error,
110
- "Unknown error occured: #{ex.message} (#{ex.class})"
111
- end
112
-
113
- @amqp_channel = @amqp_session.create_channel
97
+ @logger = logger
98
+ @amqp_channel = amqp_session.create_channel
114
99
 
115
100
  begin
101
+ @logger.debug(logloc) { "Initializing exchange #{exchange_name.inspect}" }
116
102
  @amqp_exchange = @amqp_channel.exchange(
117
103
  exchange_name,
118
104
  type: exchange_type,
@@ -133,7 +119,35 @@ class Brown::Agent::AMQPPublisher
133
119
 
134
120
  # Publish a message to the exchange.
135
121
  #
136
- # @param payload [#to_s] the "body" of the message to send.
122
+ # A message can be provided in the `payload` parameter in one of several
123
+ # ways:
124
+ #
125
+ # * as a string, in which case the provided string is used as the message
126
+ # payload as-is, with no further processing or added metadata.
127
+ #
128
+ # * as a single-element hash, in which case the key is used as the identifier
129
+ # of the serialization format (either `:yaml` or `:json`), while the value
130
+ # is serialized by calling the appropriate conversion method (`.to_yaml` or
131
+ # `.to_json`, as appropriate) and the resulting string is used as the message
132
+ # payload. The message's `content_type` attribute is set appropriately, also,
133
+ # allowing for automatic deserialization at the receiver.
134
+ #
135
+ # This payload format is best used where you have complex structured data to
136
+ # pass around between system components with very diverse implementation
137
+ # environments, or where very loose coupling is desired.
138
+ #
139
+ # * as an arbitrary object, in which case the object is serialized in a form
140
+ # which allows it to be transparently deserialized again into a native
141
+ # Ruby object at the other end.
142
+ #
143
+ # This payload format is most appropriate when you're deploying a
144
+ # tightly-coupled system where all the consumers are Brown agents, or at
145
+ # least all Ruby-based.
146
+ #
147
+ # @param payload [String, Hash<Symbol, Object>, Object] the content of the
148
+ # message to send. There are several possibilities for what can be passed
149
+ # here, and how it is encoded into an AMQP message, as per the description
150
+ # above.
137
151
  #
138
152
  # @param type [#to_s] override the default message type set in the
139
153
  # publisher, just for this one message.
@@ -150,6 +164,8 @@ class Brown::Agent::AMQPPublisher
150
164
  # for full details of every possible permutation.
151
165
  #
152
166
  def publish(payload, type: NoValue, routing_key: NoValue, **amqp_opts)
167
+ @logger.debug(logloc) { "Publishing message #{payload.inspect}, type: #{type.inspect}, routing_key: #{routing_key.inspect}, options: #{amqp_opts.inspect}" }
168
+
153
169
  opts = @message_defaults.merge(
154
170
  {
155
171
  type: type,
@@ -157,6 +173,30 @@ class Brown::Agent::AMQPPublisher
157
173
  }.delete_if { |_,v| v == NoValue }
158
174
  ).delete_if { |_,v| v.nil? }.merge(amqp_opts)
159
175
 
176
+ if payload.is_a?(Hash)
177
+ if payload.length != 1
178
+ raise ArgumentError,
179
+ "Payload hash must have exactly one element"
180
+ end
181
+
182
+ case payload.keys.first
183
+ when :json
184
+ @logger.debug(logloc) { "JSON serialisation activated" }
185
+ opts[:content_type] = "application/json"
186
+ payload = payload.values.first.to_json
187
+ when :yaml
188
+ @logger.debug(logloc) { "YAML serialisation activated" }
189
+ opts[:content_type] = "application/x.yaml"
190
+ payload = payload.values.first.to_yaml
191
+ else
192
+ raise ArgumentError,
193
+ "Unknown format type: #{payload.keys.first.inspect} (must be :json or :yaml)"
194
+ end
195
+ elsif !payload.is_a?(String)
196
+ payload = payload.to_yaml
197
+ opts[:content_type] = "application/vnd.brown.object.v1"
198
+ end
199
+
160
200
  if @amqp_exchange.name == "" and opts[:routing_key].nil?
161
201
  raise ExchangeError,
162
202
  "Cannot send a message to the default exchange without a routing key"
@@ -164,6 +204,7 @@ class Brown::Agent::AMQPPublisher
164
204
 
165
205
  @channel_mutex.synchronize do
166
206
  @amqp_exchange.publish(payload, opts)
207
+ @logger.debug(logloc) { "... and it's gone" }
167
208
  end
168
209
  end
169
210
  end