brown 1.1.2 → 2.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +4 -1
- data/.yardopts +1 -0
- data/LICENCE +674 -0
- data/README.md +321 -0
- data/bin/brown +72 -12
- data/brown.gemspec +31 -21
- data/lib/.gitkeep +0 -0
- data/lib/brown.rb +17 -3
- data/lib/brown/agent.rb +417 -21
- data/lib/brown/agent/amqp_message.rb +42 -0
- data/lib/brown/agent/amqp_message_mock.rb +28 -0
- data/lib/brown/agent/amqp_publisher.rb +169 -0
- data/lib/brown/agent/memo.rb +64 -0
- data/lib/brown/agent/stimulus.rb +143 -0
- data/lib/brown/test.rb +152 -0
- metadata +88 -64
- data/Gemfile +0 -3
- data/Rakefile +0 -19
- data/lib/brown/acl_loader.rb +0 -57
- data/lib/brown/acl_lookup.rb +0 -52
- data/lib/brown/amqp_errors.rb +0 -148
- data/lib/brown/logger.rb +0 -51
- data/lib/brown/message.rb +0 -73
- data/lib/brown/module_methods.rb +0 -134
- data/lib/brown/queue_definition.rb +0 -32
- data/lib/brown/queue_factory.rb +0 -33
- data/lib/brown/receiver.rb +0 -143
- data/lib/brown/sender.rb +0 -92
- data/lib/brown/util.rb +0 -58
- data/lib/smith.rb +0 -4
data/lib/.gitkeep
ADDED
File without changes
|
data/lib/brown.rb
CHANGED
@@ -1,5 +1,19 @@
|
|
1
|
-
|
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
|
-
|
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
|
-
|
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'
|
data/lib/brown/agent.rb
CHANGED
@@ -1,32 +1,428 @@
|
|
1
|
-
|
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
|
-
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
14
|
-
|
15
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
112
|
+
define_method(name) do |test=nil, &blk|
|
113
|
+
self.class.__send__(name, test, &blk)
|
114
|
+
end
|
20
115
|
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
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
|
-
|
30
|
-
|
421
|
+
# The logger for this agent.
|
422
|
+
#
|
423
|
+
# @return [Logger]
|
424
|
+
#
|
425
|
+
def logger
|
426
|
+
self.class.logger
|
31
427
|
end
|
32
428
|
end
|