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/README.md
ADDED
@@ -0,0 +1,321 @@
|
|
1
|
+
Brown is a "framework for autonomous agents". That is, essentially, a
|
2
|
+
high-falutin' way of saying that you can write some code to do some stuff.
|
3
|
+
|
4
|
+
More precisely, Brown agents are (typically) small, standalone blocks of
|
5
|
+
code (encapsulated in a single class) which wait for some stimuli, and then
|
6
|
+
react to it. Often, that stimuli is receiving a message (via an AMQP broker
|
7
|
+
such as [RabbitMQ](http://rabbitmq.org/), however an agent can do anything
|
8
|
+
it pleases (query a database, watch a filesystem, receive HTTP requests,
|
9
|
+
whatever) to get stimuli to respond to.
|
10
|
+
|
11
|
+
|
12
|
+
# Installation
|
13
|
+
|
14
|
+
It's a gem:
|
15
|
+
|
16
|
+
gem install brown
|
17
|
+
|
18
|
+
There's also the wonders of [the Gemfile](http://bundler.io):
|
19
|
+
|
20
|
+
gem 'brown'
|
21
|
+
|
22
|
+
If you're the sturdy type that likes to run from git:
|
23
|
+
|
24
|
+
rake build; gem install pkg/brown-<whatever>.gem
|
25
|
+
|
26
|
+
Or, if you've eschewed the convenience of Rubygems entirely, then you
|
27
|
+
presumably know what to do already.
|
28
|
+
|
29
|
+
|
30
|
+
# Usage
|
31
|
+
|
32
|
+
To make something an agent, you simply create a subclass of `Brown::Agent`.
|
33
|
+
You can then use a simple DSL to define "stimuli", each of which (when
|
34
|
+
triggered) cause a new instance of the class to be instantiated and a method
|
35
|
+
(specified by the stimulus) to be invoked in a separate thread. You can do
|
36
|
+
arbitrary things to detect stimuli, however there are a number of
|
37
|
+
pre-defined stimuli you can use to do standard things, like run something
|
38
|
+
periodically, or process a message on an AMQP queue.
|
39
|
+
|
40
|
+
As a very simple example, say you wanted to print `foo` every five seconds.
|
41
|
+
(Yes, you *could* do this in a loop, but humour me, would you?) Using the
|
42
|
+
built-in `every` stimuli, you could do it like this:
|
43
|
+
|
44
|
+
class FooTicker < Brown::Agent
|
45
|
+
every 5 do
|
46
|
+
puts "foo"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
FooTicker.run
|
51
|
+
|
52
|
+
To demonstrate that each trip of the timer runs in a separate thread, you
|
53
|
+
could extend this a little:
|
54
|
+
|
55
|
+
class FooTicker < Brown::Agent
|
56
|
+
every 5 do
|
57
|
+
puts "#{self} is fooing in thread #{Thread.current}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
FooTicker.run
|
62
|
+
|
63
|
+
Every five seconds, it should print out a different `FooTicker` and `Thread`
|
64
|
+
object.
|
65
|
+
|
66
|
+
To show you how `every` is implemented behind the scenes, we can implement
|
67
|
+
this directly using the generic method, `stimulate`:
|
68
|
+
|
69
|
+
class FooTicker < Brown::Agent
|
70
|
+
stimulate :foo do |worker|
|
71
|
+
sleep 5
|
72
|
+
worker.call
|
73
|
+
end
|
74
|
+
|
75
|
+
def foo
|
76
|
+
puts "#{self} is fooing in thread #{Thread.current}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
FooTicker.run
|
81
|
+
|
82
|
+
What a `stimulate` declaration says is, quite simply:
|
83
|
+
|
84
|
+
* Run this block over and over and over and over again
|
85
|
+
* When the block wants a worker to run, it should run `worker.call`
|
86
|
+
* I'll then create a new instance of the agent class, and call the method
|
87
|
+
name you passed to `stimulate` in a separate thread.
|
88
|
+
|
89
|
+
You can pass arguments to the agent method call, by giving them to
|
90
|
+
`worker.call`.
|
91
|
+
|
92
|
+
|
93
|
+
## AMQP publishing / consumption
|
94
|
+
|
95
|
+
Since message-based communication is a common pattern amongst cooperating
|
96
|
+
groups of agents, Brown comes with some helpers to make using AMQP painless.
|
97
|
+
|
98
|
+
Firstly, to publish a message, you need to declare a publisher, and then use
|
99
|
+
it somewhere. To declare a publisher, you use the `amqp_publisher` method:
|
100
|
+
|
101
|
+
class AmqpPublishingAgent < Brown::Agent
|
102
|
+
amqp_publisher :foo
|
103
|
+
end
|
104
|
+
|
105
|
+
There are a number of options you can add to this call, to set the AMQP
|
106
|
+
server URL, change the way that the AMQP exchange is declared, and a few
|
107
|
+
other things. For all the details on those, see the API docs for
|
108
|
+
{Brown::Agent.amqp_publisher}.
|
109
|
+
|
110
|
+
Once you have declared a publisher, you can send messages through it:
|
111
|
+
|
112
|
+
class AmqpPublishingAgent < Brown::Agent
|
113
|
+
amqp_publisher :foo, exchange_name: :foo, exchange_type: :fanout
|
114
|
+
|
115
|
+
every 5 do
|
116
|
+
foo.publish("FOO!")
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
The above example will perform the extremely important task of sending a
|
121
|
+
message containing the body `FOO!` every five seconds, forever. Hopefully
|
122
|
+
you can come up with some more practical uses for this functionality.
|
123
|
+
|
124
|
+
|
125
|
+
### Consuming Messages
|
126
|
+
|
127
|
+
Messages being received are just like any other stimulus: you give a block
|
128
|
+
of code to run when a message is received. In its simplest form, it looks
|
129
|
+
like this:
|
130
|
+
|
131
|
+
class AmqpListenerAgent < Brown::Agent
|
132
|
+
amqp_listener :foo do |msg|
|
133
|
+
logger.info "Received message: #{msg.payload}"
|
134
|
+
msg.ack
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
This example sets up a queue to receive messages send to the exchange `foo`,
|
139
|
+
and then simply logs every message it receives. Note the `msg.ack` call;
|
140
|
+
this is important so that the broker knows that the message has been
|
141
|
+
received and can send you another message. If you forget to do this, you'll
|
142
|
+
only ever receive one message.
|
143
|
+
|
144
|
+
The `amqp_listener` method can take a *lot* of different options to
|
145
|
+
customise how it works; you'll want to read {Brown::Agent.amqp_listener} to
|
146
|
+
find out all about it.
|
147
|
+
|
148
|
+
|
149
|
+
## Running agents on the command line
|
150
|
+
|
151
|
+
The easiest way to run agents "in production" is to use the `brown` command.
|
152
|
+
Simply pass a list of files which contain subclasses of `Brown::Agent`, and
|
153
|
+
those classes will be run in individual threads, with automatic restarting.
|
154
|
+
Convenient, huh?
|
155
|
+
|
156
|
+
|
157
|
+
## Testing
|
158
|
+
|
159
|
+
Brown comes with facilities to unit test all of your agents. Since agents
|
160
|
+
simply receive stimuli and act on them, testing is quite simple in
|
161
|
+
principle, but the parallelism inherent in agents can make them hard to test
|
162
|
+
without some extra helpers.
|
163
|
+
|
164
|
+
To enable the additional testing helpers, you must `require
|
165
|
+
'brown/test_helpers'` somewhere in your testing setup, before you define
|
166
|
+
your agents. This will add a bunch of extra methods, defined in
|
167
|
+
{Brown::TestHelpers} to {Brown::Agent}, which you can then call to examine
|
168
|
+
certain aspects of the agent (such as `memo?(name)` and
|
169
|
+
`amqp_publisher?(name)`) as well as send stimuli to the agent and have it
|
170
|
+
behave appropriately, which you can then make assertions about (either by
|
171
|
+
examining the new state of the overall system, or through the use of
|
172
|
+
mocks/spies).
|
173
|
+
|
174
|
+
While full documentation for all of the helper methods are available in the
|
175
|
+
YARD docs for {Brown::TestHelpers}, here are some specific tips for using
|
176
|
+
them to test certain aspects of your agents in popular testing frameworks.
|
177
|
+
|
178
|
+
|
179
|
+
### RSpec
|
180
|
+
|
181
|
+
To test a directly declared stimulus, you don't need to do very much -- you
|
182
|
+
can just instantiate the agent class and call the method you want:
|
183
|
+
|
184
|
+
class StimulationAgent < Brown::Agent
|
185
|
+
stimulate :foo do |worker|
|
186
|
+
# Something something
|
187
|
+
worker.call
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
describe StimulationAgent do
|
192
|
+
it "does something" do
|
193
|
+
subject.foo
|
194
|
+
|
195
|
+
expect(something).to eq(something_else)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
For memos, you can assert that an agent has a given memo quite easily:
|
200
|
+
|
201
|
+
class MemoAgent < Brown::Agent
|
202
|
+
memo :blargh do
|
203
|
+
"ohai"
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
describe MemoAgent do
|
208
|
+
it "has the memo" do
|
209
|
+
expect(MemoAgent).to have_memo(:blargh)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
Then, on top of that, you can assert the value is as you expected, because
|
214
|
+
memos are accessable at the class level:
|
215
|
+
|
216
|
+
it "has the right value" do
|
217
|
+
expect(MemoAgent.blargh(:test)).to eq("ohai")
|
218
|
+
end
|
219
|
+
|
220
|
+
Or even put it in a let:
|
221
|
+
|
222
|
+
context "value" do
|
223
|
+
let(:value) { MemoAgent.blargh(:test) }
|
224
|
+
end
|
225
|
+
|
226
|
+
Note in the above examples that we passed the special value `:test` to the
|
227
|
+
call to `.blargh`; that was to let it know that we're definitely testing it
|
228
|
+
out. Recall that, ordinarily, a memo that is declared "unsafe" can only be
|
229
|
+
accessed inside a block passed to the memo method. For testing purposes,
|
230
|
+
rather than having to pass a block, we instead just pass in the special
|
231
|
+
`:test` symbol and it'll let us get the value back. Note that this won't
|
232
|
+
work unless you have `require`d `'brown/test_helpers'` *before* you defined
|
233
|
+
the agent class.
|
234
|
+
|
235
|
+
Testing timers is pretty straightforward, too; just trigger away:
|
236
|
+
|
237
|
+
class TimerAgent < Brown::Agent
|
238
|
+
every 10 do
|
239
|
+
$stderr.puts "Tick tock"
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
describe TimerAgent do
|
244
|
+
it "goes off on time" do
|
245
|
+
expect($stderr).to receive(:info).with("Tick tock")
|
246
|
+
|
247
|
+
TimerAgent.trigger(10)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
It is pretty trivial to assert that some particular message was published
|
252
|
+
via AMQP:
|
253
|
+
|
254
|
+
class PublishTimerAgent < Brown::Agent
|
255
|
+
amqp_publisher :time
|
256
|
+
|
257
|
+
every 86400 do
|
258
|
+
time.publish "One day more!"
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
describe PublishTimerAgent do
|
263
|
+
it "publishes to schedule" do
|
264
|
+
expect(PublishTimerAgent.time).to receive(:publish).with("One day more!")
|
265
|
+
|
266
|
+
PublishTimerAgent.trigger(86400)
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
Testing what happens when a particular message gets received isn't much
|
271
|
+
trickier:
|
272
|
+
|
273
|
+
class ReceiverAgent < Brown::Agent
|
274
|
+
amqp_listener "some_exchange" do |msg|
|
275
|
+
$stderr.puts "Message: #{msg.payload}"
|
276
|
+
msg.ack
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
describe ReceiverAgent do
|
281
|
+
it "receives the message OK" do
|
282
|
+
expect($stderr).to receive(:puts).with("Message: ohai!")
|
283
|
+
|
284
|
+
was_acked = ReceiverAgent.amqp_receive("some_exchange", "ohai!")
|
285
|
+
expect(was_acked).to be(true)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
|
290
|
+
### Minitest / other testing frameworks
|
291
|
+
|
292
|
+
I don't have any examples for other testing frameworks, because I only use
|
293
|
+
RSpec. Contributions on this topic would be greatly appreciated.
|
294
|
+
|
295
|
+
|
296
|
+
# Contributing
|
297
|
+
|
298
|
+
Bug reports should be sent to the [Github issue
|
299
|
+
tracker](https://github.com/mpalmer/brown/issues), or
|
300
|
+
[e-mailed](mailto:theshed+brown@hezmatt.org). Patches can be sent as a
|
301
|
+
Github pull request, or [e-mailed](mailto:theshed+brown@hezmatt.org).
|
302
|
+
|
303
|
+
|
304
|
+
# Licence
|
305
|
+
|
306
|
+
Unless otherwise stated, everything in this repo is covered by the following
|
307
|
+
copyright notice:
|
308
|
+
|
309
|
+
Copyright (C) 2015 Matt Palmer <matt@hezmatt.org>
|
310
|
+
|
311
|
+
This program is free software: you can redistribute it and/or modify it
|
312
|
+
under the terms of the GNU General Public License version 3, as
|
313
|
+
published by the Free Software Foundation.
|
314
|
+
|
315
|
+
This program is distributed in the hope that it will be useful,
|
316
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
317
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
318
|
+
GNU General Public License for more details.
|
319
|
+
|
320
|
+
You should have received a copy of the GNU General Public License
|
321
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
data/bin/brown
CHANGED
@@ -1,31 +1,91 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
1
3
|
# Run an agent. Any agent.
|
2
4
|
|
3
5
|
require 'envied'
|
6
|
+
require 'logger'
|
4
7
|
|
5
8
|
envied_config = (ENVied.config || ENVied::Configuration.new).tap do |cfg|
|
6
9
|
cfg.enable_defaults!
|
7
|
-
cfg.variable :
|
8
|
-
cfg.variable :RABBITMQ_SERVER
|
10
|
+
cfg.variable :AMQP_URL, :string, :default => "amqp://localhost"
|
9
11
|
cfg.variable :BROWN_LOG_LEVEL, :string, :default => "info"
|
10
12
|
end
|
11
13
|
|
12
14
|
ENVied.require(:default, :config => envied_config)
|
13
15
|
|
14
|
-
|
15
|
-
# we should be the only thing in $LOAD_PATH at the moment, our version will
|
16
|
-
# be loaded and the real smith will never darken our door.
|
17
|
-
require 'smith'
|
18
|
-
|
19
|
-
require *ARGV
|
16
|
+
ARGV.each { |f| require f }
|
20
17
|
|
21
18
|
agent_classes = ObjectSpace.each_object(Class).select do |k|
|
22
19
|
k != Brown::Agent and k.ancestors.include?(Brown::Agent)
|
23
20
|
end
|
24
21
|
|
25
|
-
Brown.
|
26
|
-
|
22
|
+
Brown::Agent.logger = Logger.new($stderr)
|
23
|
+
Brown::Agent.logger.level = Logger.const_get(ENVied.BROWN_LOG_LEVEL.upcase.to_sym)
|
24
|
+
Brown::Agent.logger.formatter = proc { |s,dt,n,msg| "#{$$} [#{s[0]}] #{msg}\n" }
|
25
|
+
|
26
|
+
agents = ThreadGroup.new
|
27
|
+
|
28
|
+
def stop_agents(agents)
|
29
|
+
agents.list.each do |th|
|
30
|
+
th[:agent_class] && th[:agent_class].stop
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
Signal.trap("INT") do
|
35
|
+
$stderr.puts "Received SIGINT; stopping agents and exiting"
|
36
|
+
stop_agents(agents)
|
37
|
+
end
|
38
|
+
Signal.trap("TERM") do
|
39
|
+
$stderr.puts "Received SIGTERM; stopping agents and exiting"
|
40
|
+
stop_agents(agents)
|
41
|
+
end
|
42
|
+
Signal.trap("HUP", "IGNORE")
|
43
|
+
Signal.trap("USR1") do
|
44
|
+
Brown::Agent.more_log_detail
|
45
|
+
$stderr.puts "Log level now #{Logger::SEV_LABEL[Brown::Agent.logger.level]}"
|
46
|
+
end
|
47
|
+
Signal.trap("USR2") do
|
48
|
+
Brown::Agent.less_log_detail
|
49
|
+
$stderr.puts "Log level now #{Logger::SEV_LABEL[Brown::Agent.logger.level]}"
|
50
|
+
end
|
51
|
+
|
52
|
+
Brown::Agent.logger.info { "Brown starting up..." }
|
53
|
+
|
54
|
+
agent_classes.each do |klass|
|
55
|
+
th = Thread.new(klass) do |klass|
|
56
|
+
klass.run
|
57
|
+
Thread[:agent_class] = klass
|
58
|
+
end
|
59
|
+
|
60
|
+
agents.add(th)
|
61
|
+
|
62
|
+
Brown::Agent.logger.info { "Started agent #{klass}" }
|
63
|
+
end
|
64
|
+
|
65
|
+
loop do
|
66
|
+
sleep 1
|
67
|
+
|
68
|
+
agents.list.each do |th|
|
69
|
+
unless th.alive?
|
70
|
+
begin
|
71
|
+
th.join
|
72
|
+
rescue Exception => ex
|
73
|
+
Brown::Agent.logger.fatal { "Agent #{th[:agent_class]} crashed: #{ex.message} (#{ex.class})" }
|
74
|
+
Brown::Agent.logger.info { ex.backtrace.map { |l| " #{l}" }.join("\n") }
|
75
|
+
else
|
76
|
+
Brown::Agent.logger.warn "Agent #{th[:agent_class]} terminated itself"
|
77
|
+
end
|
78
|
+
klass = th[:agent_class]
|
79
|
+
th[:agent_class] = nil
|
80
|
+
|
81
|
+
Brown::Agent.logger.info { "Re-starting #{klass} agent" }
|
27
82
|
|
28
|
-
|
83
|
+
th = Thread.new(klass) do |klass|
|
84
|
+
klass.run
|
85
|
+
Thread[:agent_class] = klass
|
86
|
+
end
|
29
87
|
|
30
|
-
|
88
|
+
agents.add(th)
|
89
|
+
end
|
90
|
+
end
|
31
91
|
end
|
data/brown.gemspec
CHANGED
@@ -1,30 +1,40 @@
|
|
1
|
-
|
1
|
+
begin
|
2
|
+
require 'git-version-bump'
|
3
|
+
rescue LoadError
|
4
|
+
nil
|
5
|
+
end
|
2
6
|
|
3
7
|
Gem::Specification.new do |s|
|
4
|
-
s.name
|
5
|
-
s.version = GVB.version
|
6
|
-
s.date = GVB.date
|
7
|
-
|
8
|
-
s.summary = "Run individual smith agents directly from the command line"
|
8
|
+
s.name = "brown"
|
9
9
|
|
10
|
-
s.
|
10
|
+
s.version = GVB.version rescue "0.0.0.1.NOGVB"
|
11
|
+
s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d")
|
11
12
|
|
12
|
-
s.
|
13
|
+
s.platform = Gem::Platform::RUBY
|
13
14
|
|
14
|
-
s.
|
15
|
-
s.executables = %w{brown}
|
15
|
+
s.summary = "Autonomous agent framework"
|
16
16
|
|
17
|
-
s.
|
17
|
+
s.authors = ["Matt Palmer"]
|
18
|
+
s.email = ["theshed+brown@hezmatt.org"]
|
19
|
+
s.homepage = "http://theshed.hezmatt.org/brown"
|
18
20
|
|
19
|
-
s.
|
20
|
-
s.
|
21
|
-
s.add_runtime_dependency "eventmachine-le", "~> 1.0"
|
22
|
-
s.add_runtime_dependency "extlib", "~> 0.9"
|
23
|
-
s.add_runtime_dependency "murmurhash3", "~> 0.1"
|
24
|
-
s.add_runtime_dependency "protobuf", "~> 3.0"
|
21
|
+
s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ }
|
22
|
+
s.executables = %w{brown}
|
25
23
|
|
26
|
-
s.
|
27
|
-
|
28
|
-
s.
|
29
|
-
s.
|
24
|
+
s.required_ruby_version = ">= 2.1.0"
|
25
|
+
|
26
|
+
s.add_runtime_dependency "bunny", "~> 1.7"
|
27
|
+
s.add_runtime_dependency "envied", "~> 0.8"
|
28
|
+
|
29
|
+
s.add_development_dependency 'bundler'
|
30
|
+
s.add_development_dependency 'github-release'
|
31
|
+
s.add_development_dependency 'guard-spork'
|
32
|
+
s.add_development_dependency 'guard-rspec'
|
33
|
+
s.add_development_dependency 'pry-byebug'
|
34
|
+
s.add_development_dependency 'rake', '~> 10.4', '>= 10.4.2'
|
35
|
+
# Needed for guard
|
36
|
+
s.add_development_dependency 'rb-inotify', '~> 0.9'
|
37
|
+
s.add_development_dependency 'redcarpet'
|
38
|
+
s.add_development_dependency 'rspec'
|
39
|
+
s.add_development_dependency 'yard'
|
30
40
|
end
|