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