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