brown 1.1.2 → 2.1.0

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.
File without changes
@@ -1,5 +1,19 @@
1
- module Brown; end
1
+ # The "core" Brown module. Nothing actually lives here.
2
+ module Brown
3
+ #:nodoc:
4
+ # Signals to a running stimulus or worker that it needs to die.
5
+ #
6
+ class StopSignal < Exception; end
2
7
 
3
- Dir["#{__dir__}/brown/*.rb"].each { |f| require f }
8
+ #:nodoc:
9
+ # Signals to a running stimulus or worker that it needs to finish off
10
+ # what it is doing and then terminate.
11
+ #
12
+ class FinishSignal < Exception; end
13
+ end
4
14
 
5
- Brown.extend Brown::ModuleMethods
15
+ require_relative 'brown/agent'
16
+ require_relative 'brown/agent/amqp_message'
17
+ require_relative 'brown/agent/amqp_publisher'
18
+ require_relative 'brown/agent/memo'
19
+ require_relative 'brown/agent/stimulus'
@@ -1,32 +1,428 @@
1
- # -*- encoding: utf-8 -*-
2
-
3
- require "brown/logger"
1
+ require 'logger'
2
+ require 'securerandom'
4
3
 
4
+ # A Brown Agent. The whole reason we're here.
5
+ #
6
+ # An agent is the fundamental unit of work in the Brown universe. Pretty
7
+ # much everything you do to an agent is done on its class; the individual
8
+ # instances of the agent are run by the agent, internally, when reacting to
9
+ # stimuli.
10
+ #
5
11
  class Brown::Agent
6
- include Brown::Logger
12
+ class << self
13
+ # Define a generic stimulus for this agent.
14
+ #
15
+ # This is a fairly low-level method, designed to provide a means for
16
+ # defining stimuli for which there isn't a higher-level, more-specific
17
+ # stimulus definition approach.
18
+ #
19
+ # When the agent is started (see {.run}), the block you provide will
20
+ # be executed in a dedicated thread. Every time the block finishes,
21
+ # it will be run again. Your block should do whatever it needs to do
22
+ # to detect when a stimuli is available (preferably by blocking
23
+ # somehow, rather than polling, because polling sucks). When your
24
+ # code detects that a stimulus has been received, it should run
25
+ # `worker.call`, passing in any arguments that are required to process
26
+ # the stimulus. That will then create a new instance of the agent
27
+ # class, and call the specified `method_name` on that instance,
28
+ # passing in the arguments that were passed to `worker.call`.
29
+ #
30
+ # @see .every
31
+ #
32
+ # @param method_name [Symbol] the name of the method to call when the
33
+ # stimulus is triggered.
34
+ #
35
+ # @yieldparam worker [Proc] call this when you want a stimulus
36
+ # processed, passing in anything that the stimulus processing method
37
+ # (as specified by `method_name`) needs to do its job.
38
+ #
39
+ def stimulate(method_name, &blk)
40
+ @stimuli ||= []
7
41
 
8
- # Override this method to implement your own agent.
9
- def run
10
- raise ArgumentError, "You must override this method"
11
- end
42
+ @stimuli << Brown::Agent::Stimulus.new(
43
+ method_name: method_name,
44
+ stimuli_proc: blk,
45
+ agent_class: self,
46
+ logger: logger
47
+ )
48
+ end
12
49
 
13
- def receiver(queue_name, opts={}, &blk)
14
- queues.receiver(queue_name, opts, &blk)
15
- end
50
+ # Define a "memo" for this agent.
51
+ #
52
+ # A "memo" is an object which is common across all instances of a
53
+ # particular agent, and which is (usually) local to that agent. The
54
+ # intended purpose is for anything that is needed for processing
55
+ # stimuli, but which you don't want to recreate for every stimuli.
56
+ # Examples of this sort of thing include connection pools (database,
57
+ # HTTP connections, etc), config files, and caches. Basically,
58
+ # anything that has a non-trivial setup time, or which you *want* to
59
+ # share across all stimuli processing, should go in a memo.
60
+ #
61
+ # Because we do everything in threads, and because dealing with
62
+ # locking by hand is a nightmare, access to memos is, by default,
63
+ # protected by a mutex. This means that any time you want to do
64
+ # something with a memo, you call its name and pass a block to do
65
+ # whatever you want to do with the value, like this:
66
+ #
67
+ # config { |cfg| puts "foo is #{cfg[:foo]}" }
68
+ #
69
+ # Now, you can, if you want, "leak" this object out of the mutex, with
70
+ # various sorts of assignment. DO NOT SUCCUMB TO THIS TEMPTATION. If
71
+ # you do this, you will risk all sorts of concurrency bugs, where two
72
+ # threads try to read and/or manipulate the object at the same time
73
+ # and all hell breaks loose.
74
+ #
75
+ # If, *and only if*, you are **100% confident** that the
76
+ # object you want to work with is, in fact, entirely thread-safe (the
77
+ # documentation should mention this), then you can mark a memo object
78
+ # as "safe", either by passing `true` to {.memo}, or using the handy-dandy
79
+ # {.safe_memo} method. In this case, you can just reference the memo
80
+ # name wherever you like:
81
+ #
82
+ # safe_memo :db do
83
+ # Sequel.connect("postgres:///app_database")
84
+ # end
85
+ #
86
+ # #...
87
+ # db[:foo].where { :baz > 42 }
88
+ #
89
+ # Note that there is intentionally no way to reassign a memo object.
90
+ # This doesn't mean that memo objects are "read-only", however. The
91
+ # state of the object can be mutated by calling any method on the
92
+ # object that modifies it. If you want more read-only(ish) memos, you
93
+ # probably want to call `#freeze` on your object when you create it
94
+ # (although all the usual caveats about `#freeze` still apply).
95
+ #
96
+ # @see .safe_memo
97
+ #
98
+ # @param name [Symbol] the name of the memo, and hence the name of the
99
+ # method that should be called to retrieve the memo's value.
100
+ #
101
+ # @param safe [Boolean] whether or not the object will be "safe" for
102
+ # concurrent access by multiple threads. Do *not* enable this
103
+ # unless you are completely sure.
104
+ #
105
+ # @return void
106
+ #
107
+ def memo(name, safe=false, &generator)
108
+ name = name.to_sym
109
+ @memos ||= {}
110
+ @memos[name] = Brown::Agent::Memo.new(generator, safe)
16
111
 
17
- def sender(queue_name, opts={}, &blk)
18
- queues.sender(queue_name, opts, &blk)
19
- end
112
+ define_method(name) do |test=nil, &blk|
113
+ self.class.__send__(name, test, &blk)
114
+ end
20
115
 
21
- class << self
22
- # I care not for your opts... this is just here for Smith compatibility
23
- def options(opts)
116
+ self.singleton_class.__send__(:define_method, name) do |test=nil, &blk|
117
+ memos[name].value(test, &blk)
118
+ end
119
+ end
120
+
121
+ # A variant of {.memo} which is intended for objects which are
122
+ # inherently thread-safe within themselves.
123
+ #
124
+ # @see .memo
125
+ #
126
+ def safe_memo(name, &generator)
127
+ memo(name, true, &generator)
128
+ end
129
+
130
+ # Execute a block of code periodically.
131
+ #
132
+ # This pretty much does what it says on the tin. Every
133
+ # `n` seconds (where `n` can be a float) the given block
134
+ # of code is executed.
135
+ #
136
+ # Don't expect too much precision in the interval; we just sleep
137
+ # between triggers, so there might be a bit of an extra delay between
138
+ # invocations.
139
+ #
140
+ # @param n [Numeric] The amount of time which should elapse between
141
+ # invocations of the block.
142
+ #
143
+ # @yield every `n` seconds.
144
+ #
145
+ def every(n, &blk)
146
+ method_name = ("every_#{n}__" + SecureRandom.uuid).to_sym
147
+ define_method(method_name, &blk)
148
+
149
+ stimulate(method_name) { |worker| sleep n; worker.call }
150
+ end
151
+
152
+ # Declare an AMQP publisher, and create an AMQP exchange to publish to.
153
+ #
154
+ # On the assumption that you already know [how exchanges
155
+ # work](http://www.rabbitmq.com/tutorials/amqp-concepts.html), I'll
156
+ # just dive right in.
157
+ #
158
+ # This method creates an accessor method on your agent named after the
159
+ # symbol you pass in as `name`, which returns an instance of
160
+ # `Brown::Agent::AMQPPublisher`. This object, in turn, defines an
161
+ # AMQP exchange when it is created, and has a
162
+ # `publish` method on it (see {Brown::Agent::AMQPPublisher#publish}) which
163
+ # sends arbitrary messages to the exchange.
164
+ #
165
+ # @param name [Symbol] the name of the accessor method to call when
166
+ # you want to reference this publisher in your agent code.
167
+ #
168
+ # @param publisher_opts [Hash] options which are passed to
169
+ # {Brown::Agent::AMQPPublisher#initialize}.
170
+ #
171
+ # This method is a thin shim around {Brown::Agent::AMQPPublisher#initialize};
172
+ # you should read that method's documentation for details of what
173
+ # constitutes valid `publisher_opts`, and also what exceptions can be
174
+ # raised.
175
+ #
176
+ # @see Brown::Agent::AMQPPublisher#initialize
177
+ # @see Brown::Agent::AMQPPublisher#publish
178
+ #
179
+ def amqp_publisher(name, publisher_opts = {})
180
+ opts = { :exchange_name => name }.merge(publisher_opts)
181
+
182
+ safe_memo(name) { Brown::Agent::AMQPPublisher.new(opts) }
183
+ end
184
+
185
+ # Listen for messages from an AMQP broker.
186
+ #
187
+ # We setup a queue, bound to the exchange specified by the
188
+ # `exchange_name` argument, and then proceed to hoover up all the
189
+ # messages we can.
190
+ #
191
+ # The name of the queue that is created by default is derived from the
192
+ # agent class name and the exchange name being bound to. This allows
193
+ # for multiple instances of the same agent, running in separate
194
+ # processes or machines, to share the same queue of messages to
195
+ # process, for throughput or redundancy reasons.
196
+ #
197
+ # @param exchange_name [#to_s, Array<#to_s>] the name of the exchange
198
+ # to bind to. You can also specify an array of exchange names, to
199
+ # have all of them put their messages into the one queue. This can
200
+ # be dangerous, because you need to make sure that your message
201
+ # handler can process the different types of messages that might be
202
+ # sent to the different exchangs.
203
+ #
204
+ # @param queue_name [#to_s] the name of the queue to create, if you
205
+ # don't want to use the class-derived default for some reason.
206
+ #
207
+ # @param amqp_url [#to_s] the URL of the AMQP broker to connect to.
208
+ #
209
+ # @param concurrency [Integer] how many messages to process in parallel.
210
+ # The default, `1`, means that a message will need to be acknowledged
211
+ # (by calling `message.ack`) in your worker `blk` before the broker
212
+ # will consider sending another.
213
+ #
214
+ # If your agent is capable of processing more than one message in
215
+ # parallel (because the agent spends a lot of its time waiting for
216
+ # databases or HTTP requests, for example, or perhaps you're running
217
+ # your agents in a Ruby VM which has no GIL) you should increase
218
+ # this value to improve performance. Alternately, if you want/need
219
+ # to batch processing (say, you insert 100 records into a database
220
+ # in a single query) you'll need to increase this to get multiple
221
+ # records at once.
222
+ #
223
+ # Setting this to `0` is only for the adventurous. It tells the
224
+ # broker to send your agent messages as fast as it can. You still
225
+ # need to acknowledge the messages as you finish processing them
226
+ # (otherwise the broker will not consider them "delivered") but you
227
+ # will always be sent more messages if there are more to send, even
228
+ # if you never acknowledge any of them. This *can* get you into an
229
+ # awful lot of trouble if you're not careful, so don't do it just
230
+ # because you can.
231
+ #
232
+ # @param blk [Proc] is called every time a message is received from
233
+ # the queue, and an instance of {Brown::Agent::AMQPMessage} will
234
+ # be passed as the sole argument.
235
+ #
236
+ # @yieldparam message [Brown::Agent::AMQPMessage] is passed to `blk`
237
+ # each time a message is received from the queue.
238
+ #
239
+ def amqp_listener(exchange_name = "",
240
+ queue_name: nil,
241
+ amqp_url: "amqp://localhost",
242
+ concurrency: 1,
243
+ &blk
244
+ )
245
+ listener_uuid = SecureRandom.uuid
246
+ worker_method = "amqp_listener_worker_#{listener_uuid}".to_sym
247
+ queue_memo = "amqp_listener_queue_#{listener_uuid}".to_sym
248
+
249
+ exchange_list = Array === exchange_name ?
250
+ exchange_name :
251
+ [exchange_name]
252
+
253
+ munged_exchange_list = exchange_list.map { |n| n.to_s == "" ? "" : "-#{n.to_s}" }.join
254
+ queue_name ||= self.name.to_s + munged_exchange_list
255
+
256
+ memo(queue_memo) do
257
+ begin
258
+ amqp = Bunny.new(amqp_url, logger: logger)
259
+ amqp.start
260
+ rescue Bunny::TCPConnectionFailed
261
+ logger.error { "Failed to connect to #{amqp_url}" }
262
+ sleep 5
263
+ retry
264
+ rescue Bunny::PossibleAuthenticationFailureError
265
+ logger.error { "Authentication failure for #{amqp_url}" }
266
+ sleep 5
267
+ retry
268
+ rescue StandardError => ex
269
+ logger.error { "Unknown error while trying to connect to #{amqp_url}: #{ex.message} (#{ex.class})" }
270
+ sleep 5
271
+ retry
272
+ end
273
+
274
+ bind_queue(
275
+ amqp_session: amqp,
276
+ queue_name: queue_name,
277
+ exchange_list: exchange_list,
278
+ concurrency: concurrency
279
+ )
280
+ end
281
+
282
+ define_method(worker_method, &blk)
283
+
284
+ stimulate(worker_method) do |worker|
285
+ __send__(queue_memo) do |queue|
286
+ queue.subscribe(manual_ack: true, block: true) do |di, prop, payload|
287
+ yield Brown::Agent::AMQPMessage.new(di, prop, payload)
288
+ end
289
+ end
290
+ end
291
+ end
292
+
293
+ # Start the agent running.
294
+ #
295
+ # This fires off the stimuli listeners and then waits. If you want to
296
+ # do anything else while this runs, you'll want to fire this in a
297
+ # separate thread.
298
+ #
299
+ def run
300
+ begin
301
+ # Some memos (AMQPPublisher being the first) work best when
302
+ # initialized when the agent starts up. At some point in the
303
+ # future, we might implement selective initialisation, but for
304
+ # now we'll take the brutally-simple approach of getting
305
+ # everything.
306
+ (@memos || {}).keys.each { |k| send(k, &->(_){}) }
307
+
308
+ @thread_group ||= ThreadGroup.new
309
+ @runner_thread = Thread.current
310
+
311
+ (stimuli || {}).each do |s|
312
+ @thread_group.add(
313
+ Thread.new(s) do |s|
314
+ begin
315
+ s.run
316
+ rescue Brown::StopSignal
317
+ # OK then
318
+ end
319
+ end
320
+ )
321
+ end
322
+
323
+ @thread_group.list.each do |th|
324
+ begin
325
+ th.join
326
+ rescue Brown::StopSignal
327
+ # OK then
328
+ end
329
+ end
330
+ rescue Exception => ex
331
+ logger.error { "Agent #{self} caught unhandled exception: #{ex.message} (#{ex.class})" }
332
+ logger.info { ex.backtrace.map { |l| " #{l}" }.join("\n") }
333
+ stop
334
+ end
24
335
  end
25
- end
26
336
 
27
- protected
337
+ # Stop the agent running.
338
+ #
339
+ # This can either be called in a separate thread, or a signal handler, like
340
+ # so:
341
+ #
342
+ # Signal.trap("INT") { SomeAgent.stop }
343
+ #
344
+ # agent.run
345
+ #
346
+ def stop
347
+ (@thread_group.list rescue []).each do |th|
348
+ th.raise Brown::FinishSignal.new("agent finish")
349
+ end
350
+
351
+ (@thread_group.list rescue []).each do |th|
352
+ th.join
353
+ end
354
+ end
355
+
356
+ # Set the logger that this agent will use to report problems.
357
+ #
358
+ # @param l [Logger]
359
+ #
360
+ def logger=(l)
361
+ @logger = l
362
+ end
363
+
364
+ # Get or set the logger for this agent.
365
+ #
366
+ # @param l [Logger]
367
+ #
368
+ # @return [Logger]
369
+ #
370
+ def logger(l=nil)
371
+ (@logger = (l || @logger)) || (self == Brown::Agent ? Logger.new($stderr) : Brown::Agent.logger)
372
+ end
373
+
374
+ def more_log_detail
375
+ logger.level -= 1
376
+ end
377
+
378
+ def less_log_detail
379
+ logger.level += 1
380
+ end
381
+
382
+ private
383
+
384
+ # The available stimuli.
385
+ #
386
+ # @return [Array<Brown::Agent::Stimulus>]
387
+ #
388
+ attr_reader :stimuli
389
+
390
+ # The available memos.
391
+ #
392
+ # @return [Hash<Symbol, Brown::Agent::Memo>]
393
+ #
394
+ attr_reader :memos
395
+
396
+ def bind_queue(amqp_session:, queue_name:, exchange_list:, concurrency:)
397
+ ch = amqp_session.create_channel
398
+ ch.prefetch(concurrency)
399
+
400
+ ch.queue(queue_name, durable: true).tap do |q|
401
+ exchange_list.each do |exchange_name|
402
+ if exchange_name != ""
403
+ begin
404
+ q.bind(exchange_name)
405
+ rescue Bunny::NotFound => ex
406
+ logger.error { "bind failed: #{ex.message}" }
407
+ sleep 5
408
+ return bind_queue(
409
+ amqp_session: amqp_session,
410
+ queue_name: queue_name,
411
+ exchange_list: exchange_list,
412
+ concurrency: concurrency
413
+ )
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end
28
420
 
29
- def queues
30
- @queues ||= Brown::QueueFactory.new
421
+ # The logger for this agent.
422
+ #
423
+ # @return [Logger]
424
+ #
425
+ def logger
426
+ self.class.logger
31
427
  end
32
428
  end