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
@@ -0,0 +1,42 @@
|
|
1
|
+
# A message received from an AMQP broker.
|
2
|
+
#
|
3
|
+
# This is what you will get passed to you when you use
|
4
|
+
# {Brown::Agent.amqp_listener} to point a shell-like at an exchange. It
|
5
|
+
# allows you to get the message itself, all of the message's metadata, and
|
6
|
+
# also act on the message by acknowledging it so the broker can consider it
|
7
|
+
# "delivered".
|
8
|
+
#
|
9
|
+
class Brown::Agent::AMQPMessage
|
10
|
+
# The raw body of the message. No translation is done on what was sent,
|
11
|
+
# so any serialisation that might have been applied to the message will
|
12
|
+
# have to be undone manually.
|
13
|
+
#
|
14
|
+
# @return [String]
|
15
|
+
#
|
16
|
+
attr_reader :payload
|
17
|
+
|
18
|
+
# Create a new message. The arguments are a straight copy of what you
|
19
|
+
# get yielded from `Bunny::Queue#subscribe`, or what gets returned from
|
20
|
+
# `Bunny::Queue#pop`.
|
21
|
+
#
|
22
|
+
# @param delivery_info [Bunny::DeliveryInfo, Bunny::GetResponse]
|
23
|
+
#
|
24
|
+
# @param properties [Bunny::MessageProperties]
|
25
|
+
#
|
26
|
+
# @param payload [String]
|
27
|
+
#
|
28
|
+
def initialize(delivery_info, properties, payload)
|
29
|
+
@delivery_info, @properties, @payload = delivery_info, properties, payload
|
30
|
+
end
|
31
|
+
|
32
|
+
# Acknowledge that this message has been processed.
|
33
|
+
#
|
34
|
+
# The broker needs to know that each message has been processed, before
|
35
|
+
# it will remove the message from the queue entirely. It also won't send
|
36
|
+
# more than a certain number of messages at once, so until you ack a
|
37
|
+
# message, you won't get another one.
|
38
|
+
#
|
39
|
+
def ack
|
40
|
+
@delivery_info.channel.ack(@delivery_info.delivery_tag)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
#:nodoc:
|
2
|
+
#
|
3
|
+
# A mock form of the AMQPMessage class, only useful for testing.
|
4
|
+
#
|
5
|
+
#
|
6
|
+
class Brown::Agent::AMQPMessageMock
|
7
|
+
attr_reader :payload
|
8
|
+
|
9
|
+
def initialize(payload:)
|
10
|
+
@payload = payload
|
11
|
+
@acked = false
|
12
|
+
end
|
13
|
+
|
14
|
+
# Record an ack.
|
15
|
+
def ack
|
16
|
+
if @acked
|
17
|
+
raise RuntimeError,
|
18
|
+
"Cannot ack a message twice"
|
19
|
+
end
|
20
|
+
|
21
|
+
@acked = true
|
22
|
+
end
|
23
|
+
|
24
|
+
# Check we acked.
|
25
|
+
def acked?
|
26
|
+
@acked
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
require 'bunny'
|
2
|
+
|
3
|
+
# Publish messages to an AMQP exchange.
|
4
|
+
#
|
5
|
+
class Brown::Agent::AMQPPublisher
|
6
|
+
#:nodoc:
|
7
|
+
# Sentinel to detect that we've been sent the "default" value,
|
8
|
+
# since `nil` can, sometimes, be a valid value.
|
9
|
+
NoValue = Module.new
|
10
|
+
|
11
|
+
# The top-level exception class for all AMQPPublisher errors.
|
12
|
+
#
|
13
|
+
# If you want to just rescue *anything* untoward relating to an AMQP
|
14
|
+
# Publisher, then catch this. If you happen to get an instance of this
|
15
|
+
# class, however, then something is wrong. More wrong than what caused
|
16
|
+
# the exception in the first place, even.
|
17
|
+
#
|
18
|
+
class Error < StandardError; end
|
19
|
+
|
20
|
+
# Indicate a problem at the level of the AMQP broker.
|
21
|
+
#
|
22
|
+
# This could be an issue connecting (name resolution failure, network
|
23
|
+
# connectivity problem, etc), or an authentication or access control
|
24
|
+
# problem. The message should indicate the exact problem.
|
25
|
+
#
|
26
|
+
class BrokerError < Error; end
|
27
|
+
|
28
|
+
# There has been a problem with the exchange itself.
|
29
|
+
#
|
30
|
+
class ExchangeError < Error; end
|
31
|
+
|
32
|
+
# Create a new AMQPPublisher.
|
33
|
+
#
|
34
|
+
# Setup an exchange in the AMQP broker, and allow the publishing of
|
35
|
+
# messages to that exchange.
|
36
|
+
#
|
37
|
+
# @param amqp_url [#to_s] the AMQP broker to connect to, specified as a
|
38
|
+
# URL. The scheme must be `amqp`. Username and password should be
|
39
|
+
# given in the standard fashion (`amqp://<user>:<pass>@<host>`).
|
40
|
+
#
|
41
|
+
# The path portion of AMQP URLs is totes spesh; if you want to
|
42
|
+
# connect to the default vhost (`/`) you either need to specify *no*
|
43
|
+
# trailing slash (ie `amqp://hostname`) or percent-encode the `/`
|
44
|
+
# vhost name (ie `amqp://hostname/%2F`). Yes, this drives me nuts,
|
45
|
+
# too.
|
46
|
+
#
|
47
|
+
# @param exchange_type [Symbol] the type of exchange to create or publish
|
48
|
+
# to. By default, the exchange is created as a *direct* exchange; this
|
49
|
+
# routes messages to their destination queue(s) based on the
|
50
|
+
# `routing_key` (set per-publisher or per-queue). Other valid values
|
51
|
+
# for this option are `:direct`, `:topic`, and `:headers`.
|
52
|
+
#
|
53
|
+
# @param exchange_name [#to_s] the name of the exchange to create or
|
54
|
+
# publish to. If not specified, then the "default" exchange is used,
|
55
|
+
# which is a direct exchange that routes to a queue with the same name
|
56
|
+
# as the routing key.
|
57
|
+
#
|
58
|
+
# @param routing_key [#to_s] The default "routing key" to attach to
|
59
|
+
# all messages sent via this publisher. This can also be set (or
|
60
|
+
# overridden) on a per-message basis; see
|
61
|
+
# {Brown::Agent::AMQPPublisher#publish}. If set to `nil`, no routing
|
62
|
+
# key will be set.
|
63
|
+
#
|
64
|
+
# @param message_type [#to_s] The default type for all messages sent via
|
65
|
+
# this publisher. This can also be set (or overridden) on a
|
66
|
+
# per-message basis; see {Brown::Agent::AMQPPublisher#publish}. If set
|
67
|
+
# to `nil`, no message type will be set by default.
|
68
|
+
#
|
69
|
+
# @param logger [Logger] somewhere to log everything.
|
70
|
+
#
|
71
|
+
# @param amqp_opts [Hash] is a "catch-all" hash for any weird and
|
72
|
+
# wonderful AMQP options you may wish to set by default for all
|
73
|
+
# messages you send via this publisher. There are quite a number of
|
74
|
+
# rather esoteric options, which are not supported especially by
|
75
|
+
# Brown::Agent::AMQPPublisher, but if you really need them, they're
|
76
|
+
# here for you. See [the relevant
|
77
|
+
# documentation](http://www.rubydoc.info/gems/bunny/Bunny/Exchange#publish-instance_method)
|
78
|
+
# for full details of every possible permutation.
|
79
|
+
#
|
80
|
+
# @raise [ArgumentError] if the parameters provided are problematic, such
|
81
|
+
# as specifying an invalid exchange type or exchange name.
|
82
|
+
#
|
83
|
+
# @raise [Brown::Agent::AMQPPublisher::BrokerError] if the attempt to
|
84
|
+
# connect to the broker fails, due to a lack of connection, or wrong
|
85
|
+
# credentials.
|
86
|
+
#
|
87
|
+
# @raise [Brown::Agent::AMQPPublisher::ExchangeError] if the attempt to
|
88
|
+
# create the exchange fails for some reason (such as the exchange
|
89
|
+
# already existing with a different configuration).
|
90
|
+
#
|
91
|
+
def initialize(amqp_url: "amqp://localhost",
|
92
|
+
exchange_type: :direct,
|
93
|
+
exchange_name: "",
|
94
|
+
routing_key: nil,
|
95
|
+
message_type: nil,
|
96
|
+
logger: Logger.new("/dev/null"),
|
97
|
+
**amqp_opts
|
98
|
+
)
|
99
|
+
begin
|
100
|
+
@amqp_session = Bunny.new(amqp_url, logger: logger)
|
101
|
+
@amqp_session.start
|
102
|
+
rescue Bunny::TCPConnectionFailed
|
103
|
+
raise BrokerError,
|
104
|
+
"Failed to connect to #{amqp_url}"
|
105
|
+
rescue Bunny::PossibleAuthenticationFailureError
|
106
|
+
raise BrokerError,
|
107
|
+
"Authentication failed for #{amqp_url}"
|
108
|
+
rescue StandardError => ex
|
109
|
+
raise Error,
|
110
|
+
"Unknown error occured: #{ex.message} (#{ex.class})"
|
111
|
+
end
|
112
|
+
|
113
|
+
@amqp_channel = @amqp_session.create_channel
|
114
|
+
|
115
|
+
begin
|
116
|
+
@amqp_exchange = @amqp_channel.exchange(
|
117
|
+
exchange_name,
|
118
|
+
type: exchange_type,
|
119
|
+
durable: true
|
120
|
+
)
|
121
|
+
rescue Bunny::PreconditionFailed => ex
|
122
|
+
raise ExchangeError,
|
123
|
+
"Failed to open exchange: #{ex.message}"
|
124
|
+
end
|
125
|
+
|
126
|
+
@message_defaults = {
|
127
|
+
:routing_key => routing_key,
|
128
|
+
:type => message_type
|
129
|
+
}.merge(amqp_opts)
|
130
|
+
|
131
|
+
@channel_mutex = Mutex.new
|
132
|
+
end
|
133
|
+
|
134
|
+
# Publish a message to the exchange.
|
135
|
+
#
|
136
|
+
# @param payload [#to_s] the "body" of the message to send.
|
137
|
+
#
|
138
|
+
# @param type [#to_s] override the default message type set in the
|
139
|
+
# publisher, just for this one message.
|
140
|
+
#
|
141
|
+
# @param routing_key [#to_s] override the default routing key set in the
|
142
|
+
# publisher, just for this one message.
|
143
|
+
#
|
144
|
+
# @param amqp_opts [Hash] is a "catch-all" hash for any weird and
|
145
|
+
# wonderful AMQP options you may wish to set. There are quite a number
|
146
|
+
# of rather esoteric options, which are not supported especially by
|
147
|
+
# Brown::Agent::AMQPPublisher, but if you really need them, they're
|
148
|
+
# here for you. See [the relevant
|
149
|
+
# documentation](http://www.rubydoc.info/gems/bunny/Bunny/Exchange#publish-instance_method)
|
150
|
+
# for full details of every possible permutation.
|
151
|
+
#
|
152
|
+
def publish(payload, type: NoValue, routing_key: NoValue, **amqp_opts)
|
153
|
+
opts = @message_defaults.merge(
|
154
|
+
{
|
155
|
+
type: type,
|
156
|
+
routing_key: routing_key
|
157
|
+
}.delete_if { |_,v| v == NoValue }
|
158
|
+
).delete_if { |_,v| v.nil? }.merge(amqp_opts)
|
159
|
+
|
160
|
+
if @amqp_exchange.name == "" and opts[:routing_key].nil?
|
161
|
+
raise ExchangeError,
|
162
|
+
"Cannot send a message to the default exchange without a routing key"
|
163
|
+
end
|
164
|
+
|
165
|
+
@channel_mutex.synchronize do
|
166
|
+
@amqp_exchange.publish(payload, opts)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
#:nodoc:
|
2
|
+
# A thread-safe agent "memo".
|
3
|
+
#
|
4
|
+
# This is the "behind the scenes" code that supports "agent memos", objects
|
5
|
+
# which are shared across all instances of a given agent.
|
6
|
+
#
|
7
|
+
# All of the interesting documentation about how agent memos work is in
|
8
|
+
# {Brown::Agent::ClassMethods.memo} and
|
9
|
+
# {Brown::Agent::ClassMethods.safe_memo}.
|
10
|
+
#
|
11
|
+
class Brown::Agent::Memo
|
12
|
+
# Spawn a new memo.
|
13
|
+
#
|
14
|
+
# @param blk [Proc] the block to call to get the value of the memo.
|
15
|
+
#
|
16
|
+
# @param safe [Boolean] whether or not the value in the memo is
|
17
|
+
# inherently thread-safe for access. This should only be set when the
|
18
|
+
# object cannot be changed, or when the object has its own locking to
|
19
|
+
# protect against concurrent access. The default is to mark the
|
20
|
+
# memoised object as "unsafe", in which case all access to the variable
|
21
|
+
# must be in a block, which is itself executed inside a mutex.
|
22
|
+
#
|
23
|
+
def initialize(blk, safe=false, test=false)
|
24
|
+
@blk = blk
|
25
|
+
@value_mutex = Mutex.new
|
26
|
+
@attr_mutex = Mutex.new
|
27
|
+
@safe = safe
|
28
|
+
@test = test
|
29
|
+
end
|
30
|
+
|
31
|
+
# Retrieve the value of the memo.
|
32
|
+
#
|
33
|
+
# @return [Object, nil] if called without a block, this will return
|
34
|
+
# the object which is the value of the memo; otherwise, `nil`.
|
35
|
+
#
|
36
|
+
# @yield [Object] the object which is the value of the memo.
|
37
|
+
#
|
38
|
+
# @raise [RuntimeError] if called on an unsafe memo without passing a
|
39
|
+
# block.
|
40
|
+
#
|
41
|
+
def value(test=nil)
|
42
|
+
if block_given?
|
43
|
+
@value_mutex.synchronize { yield cached_value }
|
44
|
+
nil
|
45
|
+
else
|
46
|
+
if @safe || (@test && test == :test)
|
47
|
+
cached_value
|
48
|
+
else
|
49
|
+
raise RuntimeError,
|
50
|
+
"Access to unsafe agent variable prohibited"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
# Retrieve or generate the cached value.
|
58
|
+
#
|
59
|
+
# @return [Object]
|
60
|
+
#
|
61
|
+
def cached_value
|
62
|
+
@attr_mutex.synchronize { @cached_value ||= @blk.call }
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# A single stimulus group in a Brown Agent.
|
2
|
+
#
|
3
|
+
# This is all the behind-the-scenes plumbing that's required to make the
|
4
|
+
# trickiness of stimuli work correctly. In general, if you're a Brown user
|
5
|
+
# and you ever need to do anything with anything in here, something has gone
|
6
|
+
# terribly, terribly wrong somewhere.
|
7
|
+
#
|
8
|
+
class Brown::Agent::Stimulus
|
9
|
+
# The name of the method to call when the stimulus is triggered.
|
10
|
+
#
|
11
|
+
# @return [String]
|
12
|
+
#
|
13
|
+
attr_reader :method_name
|
14
|
+
|
15
|
+
# The chunk of code to call over and over again to listen for the stimulus.
|
16
|
+
#
|
17
|
+
# @return [Proc]
|
18
|
+
#
|
19
|
+
attr_reader :stimuli_proc
|
20
|
+
|
21
|
+
# The class to instantiate to process a stimulus.
|
22
|
+
#
|
23
|
+
# @return [Class]
|
24
|
+
#
|
25
|
+
attr_reader :agent_class
|
26
|
+
|
27
|
+
# Create a new stimulus.
|
28
|
+
#
|
29
|
+
# @param method_name [String] The method to call on an instance of
|
30
|
+
# `agent_class` to process a single stimulus event.
|
31
|
+
#
|
32
|
+
# @param stimuli_proc [Proc] What to call over and over again to listen
|
33
|
+
# for new stimulus events.
|
34
|
+
#
|
35
|
+
# @param agent_class [Class] The class to instantiate when processing
|
36
|
+
# stimulus events.
|
37
|
+
#
|
38
|
+
# @param logger [Logger] Where to log things to for this stimulus.
|
39
|
+
# If left as the default, no logging will be done.
|
40
|
+
#
|
41
|
+
def initialize(method_name:, stimuli_proc:, agent_class:, logger: Logger.new("/dev/null"))
|
42
|
+
@method_name = method_name
|
43
|
+
@stimuli_proc = stimuli_proc
|
44
|
+
@agent_class = agent_class
|
45
|
+
@thread_group = ThreadGroup.new
|
46
|
+
@logger = logger
|
47
|
+
end
|
48
|
+
|
49
|
+
# Fire off the stimulus listener.
|
50
|
+
#
|
51
|
+
# @param once [Symbol, NilClass] Ordinarily, when the stimulus is run, it
|
52
|
+
# just keeps going forever (or until stopped, at least). If you just
|
53
|
+
# want to run the stimulus listener proc once, and then return, you can
|
54
|
+
# pass the special symbol `:once` here.
|
55
|
+
#
|
56
|
+
def run(once = nil)
|
57
|
+
if once == :once
|
58
|
+
stimuli_proc.call(->(*args) { process(*args) })
|
59
|
+
else
|
60
|
+
@runner_thread = Thread.current
|
61
|
+
begin
|
62
|
+
while @runner_thread
|
63
|
+
begin
|
64
|
+
stimuli_proc.call(method(:spawn_worker))
|
65
|
+
rescue Brown::StopSignal, Brown::FinishSignal
|
66
|
+
raise
|
67
|
+
rescue Exception => ex
|
68
|
+
log_failure("Stimuli listener", ex)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
rescue Brown::StopSignal
|
72
|
+
stop
|
73
|
+
rescue Brown::FinishSignal
|
74
|
+
finish
|
75
|
+
rescue Exception => ex
|
76
|
+
log_failure("Stimuli runner", ex)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Signal the stimulus to immediately shut down everything.
|
82
|
+
#
|
83
|
+
# This will cause all stimulus processing threads to be terminated
|
84
|
+
# immediately. You probably want to use {#finish} instead, normally.
|
85
|
+
#
|
86
|
+
def stop
|
87
|
+
@thread_group.list.each do |th|
|
88
|
+
th.raise Brown::StopSignal.new("stimulus thread_group")
|
89
|
+
end
|
90
|
+
|
91
|
+
finish
|
92
|
+
end
|
93
|
+
|
94
|
+
# Stop the stimulus listener, and wait gracefull for all currently
|
95
|
+
# in-progress stimuli processing to finish before returning.
|
96
|
+
#
|
97
|
+
def finish
|
98
|
+
if @runner_thread and @runner_thread != Thread.current
|
99
|
+
@runner_thread.raise(Brown::StopSignal.new("stimulus loop"))
|
100
|
+
end
|
101
|
+
@runner_thread = nil
|
102
|
+
|
103
|
+
@thread_group.list.each { |th| th.join }
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
# Process a single stimulus event.
|
109
|
+
#
|
110
|
+
def process(*args)
|
111
|
+
instance = agent_class.new
|
112
|
+
|
113
|
+
if instance.method(method_name).arity == 0
|
114
|
+
instance.__send__(method_name)
|
115
|
+
else
|
116
|
+
instance.__send__(method_name, *args)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# Fire off a new thread to process a single stimulus event.
|
121
|
+
#
|
122
|
+
def spawn_worker(*args)
|
123
|
+
@thread_group.add(
|
124
|
+
Thread.new(args) do |args|
|
125
|
+
begin
|
126
|
+
process(*args)
|
127
|
+
rescue Brown::StopSignal, Brown::FinishSignal
|
128
|
+
# We're OK with this; the thread will now
|
129
|
+
# quietly die.
|
130
|
+
rescue Exception => ex
|
131
|
+
log_failure("Stimulus worker", ex)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
)
|
135
|
+
end
|
136
|
+
|
137
|
+
# Standard log formatting for caught exceptions.
|
138
|
+
#
|
139
|
+
def log_failure(what, ex)
|
140
|
+
@logger.error { "#{what} failed: #{ex.message} (#{ex.class})" }
|
141
|
+
@logger.info { ex.backtrace.map { |l| " #{l}" }.join("\n") }
|
142
|
+
end
|
143
|
+
end
|