brown 1.1.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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