brown 2.2.2 → 2.2.2.25.g85ddf08

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