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