brown 1.1.2 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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