proletariat 0.0.1

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,249 @@
1
+ module Proletariat
2
+ # Internal: Creates, binds and listens on a RabbitMQ queue. Forwards
3
+ # messages to a given listener.
4
+ class Subscriber
5
+ include Concurrent::Runnable
6
+
7
+ include Concerns::Logging
8
+
9
+ # Public: Creates a new Subscriber instance.
10
+ #
11
+ # connection - An open Bunny::Session object.
12
+ # exchange_name - A String of the RabbitMQ topic exchange.
13
+ # queue_config - A QueueConfig value object.
14
+ def initialize(connection, listener, queue_config)
15
+ @connection = connection
16
+ @listener = listener
17
+ @queue_config = queue_config
18
+
19
+ @channel = @connection.create_channel
20
+
21
+ @channel.prefetch queue_config.prefetch
22
+
23
+ @exchange = @channel.topic queue_config.exchange_name, durable: true
24
+ @bunny_queue = @channel.queue queue_config.queue_name,
25
+ durable: true,
26
+ auto_delete: queue_config.auto_delete
27
+
28
+ bind_queue
29
+ end
30
+
31
+ # Internal: Called by the Concurrent framework on run. Used here to start
32
+ # consumption of the queue and to log the status of the
33
+ # subscriber.
34
+ #
35
+ # Returns nil.
36
+ def on_run
37
+ start_consumer
38
+ log_info 'Now online'
39
+
40
+ nil
41
+ end
42
+
43
+ # Internal: Called by the Concurrent framework on run. Used here to stop
44
+ # consumption of the queue and to log the status of the
45
+ # subscriber.
46
+ #
47
+ # Returns nil.
48
+ def on_stop
49
+ log_info 'Attempting graceful shutdown.'
50
+ stop_consumer
51
+ log_info 'Now offline'
52
+ end
53
+
54
+ # Internal: Called by the Concurrent framework to perform work. Used here
55
+ # acknowledge RabbitMQ messages.
56
+ #
57
+ # Returns nil.
58
+ def on_task
59
+ ready_acknowledgers.each do |acknowledger|
60
+ acknowledger.acknowledge_on_channel channel
61
+ acknowledgers.delete acknowledger
62
+ end
63
+ end
64
+
65
+ # Public: Purge the RabbitMQ queue.
66
+ #
67
+ # Returns nil.
68
+ def purge
69
+ bunny_queue.purge
70
+
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ # Internal: Returns the Bunny::Queue in use.
77
+ attr_reader :bunny_queue
78
+
79
+ # Internal: Returns the Bunny::Channel in use.
80
+ attr_reader :channel
81
+
82
+ # Internal: Returns the Bunny::Exchange in use.
83
+ attr_reader :exchange
84
+
85
+ # Internal: Returns the listener object.
86
+ attr_reader :listener
87
+
88
+ # Internal: Returns the queue_config in use.
89
+ attr_reader :queue_config
90
+
91
+ def acknowledgers
92
+ @acknowledgers ||= []
93
+ end
94
+
95
+ # Internal: Binds bunny_queue to the exchange via each routing key
96
+ # specified in the queue_config.
97
+ #
98
+ # Returns nil.
99
+ def bind_queue
100
+ queue_config.routing_keys.each do |key|
101
+ bunny_queue.bind exchange, routing_key: key
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ # Internal: Get acknowledgers for messages whose work has completed.
108
+ #
109
+ # Returns an Array of Acknowledgers.
110
+ def ready_acknowledgers
111
+ acknowledgers.select do |acknowledger|
112
+ acknowledger.ready_to_acknowledge?
113
+ end
114
+ end
115
+
116
+ # Internal: Starts a consumer on the queue. The consumer forwards all
117
+ # message bodies to listener#post.
118
+ #
119
+ # Returns nil.
120
+ def start_consumer
121
+ @consumer = bunny_queue.subscribe ack: true do |info, properties, body|
122
+ future = listener.post?(body)
123
+ acknowledgers << Acknowledger.new(future, info.delivery_tag)
124
+
125
+ nil
126
+ end
127
+
128
+ nil
129
+ end
130
+
131
+ # Internal: Stops any active consumer. Waits for acknowledgement queue to
132
+ # drain before returning.
133
+ #
134
+ # Returns nil.
135
+ def stop_consumer
136
+ @consumer.cancel if @consumer
137
+ wait_for_acknowledgers if acknowledgers.any?
138
+
139
+ nil
140
+ end
141
+
142
+ # Internal: Makes blocking calls for each unacknowledged message until all
143
+ # messages are acknowledged.
144
+ #
145
+ # Returns nil.
146
+ def wait_for_acknowledgers
147
+ log_info 'Waiting for unacknowledged messages.'
148
+ while acknowledgers.any?
149
+ acknowledger = acknowledgers.pop
150
+ acknowledger.block_until_acknowledged channel
151
+ end
152
+
153
+ nil
154
+ end
155
+
156
+ # Internal: Used to watch the state of dispatched Work and send ack/nack
157
+ # to a RabbitMQ channel.
158
+ class Acknowledger
159
+ # Public: Maximum time in seconds to wait synchronously for an
160
+ # acknowledgement.
161
+ MAX_BLOCK_TIME = 5
162
+
163
+ # Public: Creates a new Acknowledger instance.
164
+ #
165
+ # future - A future-like object holding the Worker response.
166
+ # delivery_tag - The RabbitMQ delivery tag to be used when ack/nacking.
167
+ def initialize(future, delivery_tag)
168
+ @future = future
169
+ @delivery_tag = delivery_tag
170
+ end
171
+
172
+ # Public: Retrieves the value from the future and sends the relevant
173
+ # acknowledgement on a given channel. Logs a warning if the
174
+ # future value is unexpected.
175
+ #
176
+ # channel - The Bunny::Channel to receive the acknowledgement.
177
+ #
178
+ # Returns nil.
179
+ def acknowledge_on_channel(channel)
180
+ if future.fulfilled?
181
+ acknowledge_success(channel)
182
+ elsif future.rejected?
183
+ acknowledge_error(channel)
184
+ end
185
+
186
+ nil
187
+ end
188
+
189
+ # Public: Blocks until acknowledgement completes.
190
+ #
191
+ # channel - The Bunny::Channel to receive the acknowledgement.
192
+ #
193
+ # Returns nil.
194
+ def block_until_acknowledged(channel)
195
+ future.value(MAX_BLOCK_TIME)
196
+ acknowledge_on_channel(channel)
197
+
198
+ nil
199
+ end
200
+
201
+ # Public: Gets the readiness of the future for acknowledgement use.
202
+ #
203
+ # Returns true if future is fulfilled or rejected.
204
+ def ready_to_acknowledge?
205
+ future.state != :pending
206
+ end
207
+
208
+ private
209
+
210
+ # Internal: Dispatches acknowledgements for non-errored worker responses.
211
+ # Maps symbol value to acknowledgement strategies.
212
+ #
213
+ # channel - The Bunny::Channel to receive the acknowledgement.
214
+ #
215
+ # Returns nil.
216
+ def acknowledge_success(channel)
217
+ case future.value
218
+ when :ok then channel.acknowledge delivery_tag
219
+ when :drop then channel.reject delivery_tag, false
220
+ when :requeue then channel.reject delivery_tag, true
221
+ else
222
+ Proletariat.logger.warn 'Unexpected return value from #work.'
223
+ channel.reject delivery_tag, false
224
+ end
225
+
226
+ nil
227
+ end
228
+
229
+ # Internal: Dispatches acknowledgements for errored worker responses.
230
+ # Requeues messages and logs the error.
231
+ #
232
+ # channel - The Bunny::Channel to receive the acknowledgement.
233
+ #
234
+ # Returns nil.
235
+ def acknowledge_error(channel)
236
+ Proletariat.logger.error future.reason
237
+ channel.reject delivery_tag, true
238
+
239
+ nil
240
+ end
241
+
242
+ # Internal: Returns the RabbitMQ delivery tag.
243
+ attr_reader :delivery_tag
244
+
245
+ # Internal: Returns the future-like object holding the Worker response.
246
+ attr_reader :future
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,15 @@
1
+ module Proletariat
2
+ module Testing
3
+ # Internal: Defines a quantity of messages you expect to receive on a set
4
+ # of topics.
5
+ class Expectation < Struct.new(:topics, :quantity)
6
+ # Public: Builds a new duplicate of current instance with different
7
+ # topics.
8
+ #
9
+ # Returns a new instance of Expectation.
10
+ def on_topic(*topics)
11
+ Expectation.new(topics, quantity)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,145 @@
1
+ module Proletariat
2
+ module Testing
3
+ # Internal: Executes a block and ensures given expectations are satisfied
4
+ # before continuing.
5
+ class ExpectationGuarantor
6
+ # Public: An error which will be raised if the expectation is not
7
+ # satisfied within the timeout.
8
+ class MessageTimeoutError < RuntimeError; end
9
+
10
+ # Public: Default time to wait for expectation to be satisfied.
11
+ MESSAGE_TIMEOUT = 10
12
+
13
+ # Public: Interval at which to check expectation is satisfied.
14
+ MESSAGE_CHECK_INTERVAL = 0.2
15
+
16
+ # Public: Creates a new ExpectationGuarantor instance.
17
+ #
18
+ # expectations - An Array of Expectations to be checked.
19
+ # block - The block of code within which the expectations should
20
+ # be satisfied.
21
+ def initialize(expectations, &block)
22
+ @connection = Proletariat.runner.connection
23
+ @counters = []
24
+ @subscribers = []
25
+
26
+ expectations.each do |expectation|
27
+ queue_config = generate_queue_config_for_topic(expectation.topics)
28
+ counter = MessageCounter.new(expectation.quantity)
29
+ counters << counter
30
+ subscribers << Subscriber.new(connection, counter, queue_config)
31
+ end
32
+
33
+ @block = block
34
+ end
35
+
36
+ # Public: Execute the blocks and waits for the expectations to be met.
37
+ #
38
+ # Returns nil if expectations are met within timeout.
39
+ # Raises MessageTimeoutError if expectations are not met within timeout.
40
+ def guarantee
41
+ run_subscribers
42
+
43
+ block.call
44
+
45
+ timer = 0.0
46
+
47
+ until passed?
48
+ fail MessageTimeoutError if timer > MESSAGE_TIMEOUT
49
+ sleep MESSAGE_CHECK_INTERVAL
50
+ timer += MESSAGE_CHECK_INTERVAL
51
+ end
52
+
53
+ stop_subscribers
54
+
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ # Internal: Returns the block of code in which the expectations should be
61
+ # satisfied.
62
+ attr_reader :block
63
+
64
+ # Internal: Returns an open Bunny::Session object.
65
+ attr_reader :connection
66
+
67
+ # Internal: Returns an array of MessageCounter instances.
68
+ attr_reader :counters
69
+
70
+ # Internal: Returns an array of Subscriber instances.
71
+ attr_reader :subscribers
72
+
73
+ def generate_queue_config_for_topic(topics)
74
+ QueueConfig.new('', Proletariat.runner.exchange_name, topics, 1, true)
75
+ end
76
+
77
+ # Internal: Checks each counter to ensure expected messages have arrived.
78
+ #
79
+ # Returns true if all counters are satisfied.
80
+ # Returns false if one or more counters are not satisfied.
81
+ def passed?
82
+ counters
83
+ .map(&:expected_messages_received?)
84
+ .reduce { |a, e| a && e }
85
+ end
86
+
87
+ # Internal: Starts each subscriber.
88
+ #
89
+ # Returns nil.
90
+ def run_subscribers
91
+ subscribers.each { |subscriber| subscriber.run! }
92
+
93
+ nil
94
+ end
95
+
96
+ # Internal: Stops each subscriber.
97
+ #
98
+ # Returns nil.
99
+ def stop_subscribers
100
+ subscribers.each { |subscriber| subscriber.stop }
101
+
102
+ nil
103
+ end
104
+
105
+ # Internal: Counts incoming messages to test expection satisfaction.
106
+ class MessageCounter
107
+ # Public: Creates a new MessageCounter instance.
108
+ #
109
+ # expected - The number of messages expected.
110
+ def initialize(expected)
111
+ @count = 0
112
+ @expected = expected
113
+ end
114
+
115
+ # Public: Checks whether message count satifies expected count.
116
+ #
117
+ # Returns true if count is greater or equal to expected.
118
+ # Returns false if count less than expected.
119
+ def expected_messages_received?
120
+ count >= expected
121
+ end
122
+
123
+ # Public: Handles message calls from a subscriber and increments the
124
+ # count. Return value matches interface expected by Subscriber.
125
+ #
126
+ # message - The contents of the message.
127
+ #
128
+ # Returns a future-like object holding an :ok Symbol.
129
+ def post?(message)
130
+ self.count = count + 1
131
+
132
+ Concurrent::Future.new { :ok }
133
+ end
134
+
135
+ private
136
+
137
+ # Internal: Returns the current message count.
138
+ attr_accessor :count
139
+
140
+ # Internal: Returns the expected message count.
141
+ attr_reader :expected
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,10 @@
1
+ # Public: Extends Fixnum to provide sugar for creating Expectation instances.
2
+ class Fixnum
3
+ # Public: Builds an Expectation instance which listens for a quantity of
4
+ # messages equal to self on any topic.
5
+ #
6
+ # Returns a new Expectation instance.
7
+ def messages
8
+ Proletariat::Testing::Expectation.new(['#'], self)
9
+ end
10
+ end
@@ -0,0 +1,41 @@
1
+ require 'proletariat/testing/expectation'
2
+ require 'proletariat/testing/expectation_guarantor'
3
+ require 'proletariat/testing/fixnum_extension'
4
+
5
+ module Proletariat
6
+ # Public: Mixin to aid solve test synchronization issues while still running
7
+ # Proletariat the same way you would in production,
8
+ module Testing
9
+ # Public: Builds an Expectation instance which listens for a single message
10
+ # on any topic.
11
+ #
12
+ # Returns a new Expectation instance.
13
+ def message
14
+ Proletariat::Testing::Expectation.new(['#'], 1)
15
+ end
16
+
17
+ # Public: Creates and runs a new ExpectationGuarantor from a given list of
18
+ # Expectation instances and a block.
19
+ #
20
+ # expectations - One or more Expectation instances.
21
+ # block - A block within which the expectations should be
22
+ # satisfied.
23
+ #
24
+ # Examples
25
+ #
26
+ # wait_for 3.messages.on_topic 'email_sent'
27
+ # # ... [Time passes]
28
+ # # => 'nil'
29
+ #
30
+ # wait_for message.on_topic 'hell_freezes_over'
31
+ # # ... [Time passes]
32
+ # # => MessageTimeoutError
33
+ #
34
+ # Returns nil.
35
+ def wait_for(*expectations, &block)
36
+ ExpectationGuarantor.new(expectations, &block).guarantee
37
+
38
+ nil
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,4 @@
1
+ # Public: Adds a constant for the current version number.
2
+ module Proletariat
3
+ VERSION = '0.0.1'
4
+ end
@@ -0,0 +1,131 @@
1
+ module Proletariat
2
+ # Public: Handles messages from a RabbitMQ queue. Subclasses should
3
+ # overwrite the #work method.
4
+ class Worker < Concurrent::Actor
5
+ include Concerns::Logging
6
+
7
+ # Internal: Called by the Concurrent framework to handle new mailbox
8
+ # messages. Overridden in this subclass to call the #work method
9
+ # with the given message.
10
+ #
11
+ # message - The incoming message.
12
+ #
13
+ # Returns nil.
14
+ def act(message)
15
+ work message
16
+ end
17
+
18
+ # Internal: Called by the Concurrent framework on actor start. Overridden
19
+ # in this subclass to log the status of the worker.
20
+ #
21
+ # Returns nil.
22
+ def on_run
23
+ super
24
+
25
+ log_info 'Now online'
26
+
27
+ nil
28
+ end
29
+
30
+ # Internal: Called by the Concurrent framework on actor start. Overridden
31
+ # in this subclass to log the status of the worker.
32
+ #
33
+ # Returns nil.
34
+ def on_stop
35
+ log_info 'Attempting graceful shutdown.'
36
+ wait_for_work_queue unless queue.empty?
37
+
38
+ super
39
+
40
+ log_info 'Now offline'
41
+
42
+ nil
43
+ end
44
+
45
+ # Public: Handles RabbitMQ messages.
46
+ #
47
+ # message - The incoming message.
48
+ #
49
+ # Raises NotImplementedError unless implemented in subclass.
50
+ def work(message)
51
+ fail NotImplementedError
52
+ end
53
+
54
+ protected
55
+
56
+ # Public: Helper method to ease accessing the logger from within #work.
57
+ # Sends #info to logger if message provided.
58
+ #
59
+ # Examples
60
+ #
61
+ # log 'Background Workers Unite!'
62
+ # # Message is logged at info level.
63
+ #
64
+ # log.error 'Something bad happened!'
65
+ # # Message is logged at error level.
66
+ #
67
+ # Returns the process-wide logger if message not supplied.
68
+ # Returns nil if message supplied.
69
+ def log(message = nil)
70
+ if message
71
+ Proletariat.logger.info(message)
72
+
73
+ nil
74
+ else
75
+ Proletariat.logger
76
+ end
77
+ end
78
+
79
+ # Public: Helper method to ease sending messages from within #work.
80
+ #
81
+ # to - The routing key for the message to as a String. In accordance
82
+ # with the RabbitMQ convention you can use the '*' character to
83
+ # replace one word and the '#' to replace many words.
84
+ # message - The message as a String.
85
+ #
86
+ # Returns nil.
87
+ def publish(to, message = '')
88
+ Proletariat.publish to, message
89
+
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ # Internal: Blocks until each message has been handled by #work.
96
+ #
97
+ # Returns nil.
98
+ def wait_for_work_queue
99
+ log_info 'Waiting for work queue to drain.'
100
+
101
+ work(*queue.pop.message) until queue.empty?
102
+
103
+ nil
104
+ end
105
+
106
+ # Internal: Class methods on Worker to provide configuration DSL.
107
+ module ConfigurationMethods
108
+ # Public: A configuration method for adding a routing key to be used when
109
+ # binding this worker type's queue to an exchange.
110
+ #
111
+ # routing_key - A routing key for queue-binding as a String.
112
+ #
113
+ # Returns nil.
114
+ def listen_on(routing_key)
115
+ routing_keys << routing_key
116
+
117
+ nil
118
+ end
119
+
120
+ # Internal: Returns the list of all desired routing keys for this worker
121
+ # type
122
+ #
123
+ # Returns an Array of routing keys as Strings.
124
+ def routing_keys
125
+ @routing_keys ||= []
126
+ end
127
+ end
128
+
129
+ extend ConfigurationMethods
130
+ end
131
+ end
@@ -0,0 +1,87 @@
1
+ require 'proletariat/version'
2
+
3
+ require 'concurrent'
4
+ require 'bunny'
5
+ require 'logger'
6
+ require 'forwardable'
7
+
8
+ require 'proletariat/concerns/logging'
9
+
10
+ require 'proletariat/manager'
11
+ require 'proletariat/publisher'
12
+ require 'proletariat/queue_config'
13
+ require 'proletariat/runner'
14
+ require 'proletariat/subscriber'
15
+ require 'proletariat/worker'
16
+
17
+ # Public: Creates the Proletariat namespace and holds a process-wide Runner
18
+ # instance as well as a logger.
19
+ module Proletariat
20
+ # Public: The default name used for the RabbitMQ topic exchange.
21
+ DEFAULT_EXCHANGE_NAME = 'proletariat'
22
+
23
+ class << self
24
+ extend Forwardable
25
+
26
+ # Public: Delegate lifecycle calls to the process-wide Runner.
27
+ def_delegators :runner, :run, :run!, :stop, :running?, :publish, :purge
28
+
29
+ # Public: Allows the setting of an alternate logger.
30
+ #
31
+ # logger - An object which fulfills the role of a Logger.
32
+ attr_writer :logger
33
+
34
+ # Public: Sets the process-wide Runner to an instance initialized with a
35
+ # given hash of options.
36
+ #
37
+ # options - A Hash of options (default: {}):
38
+ # :connection - An open RabbitMQ::Session object.
39
+ # :exchange_name - The RabbitMQ topic exchange name as a
40
+ # String.
41
+ # :logger - An object which fulfills the role of a
42
+ # Logger.
43
+ # :publisher_threads - The size of the publisher thread pool.
44
+ # :supervisor - A Supervisor instance.
45
+ # :worker_classes - An Array of Worker subclasses.
46
+ # :worker_threads - The size of the worker thread pool.
47
+ def configure(options = {})
48
+ self.logger = options.fetch(:logger, default_logger)
49
+
50
+ @runner = Runner.new(defaults.merge(options))
51
+ end
52
+
53
+ # Internal: The logger used if no other is specified via .configure.
54
+ #
55
+ # Returns a Logger which logs to STDOUT.
56
+ def default_logger
57
+ Logger.new(STDOUT)
58
+ end
59
+
60
+ # Internal: Default process-wide Runner options.
61
+ #
62
+ # Returns a Hash of options.
63
+ def defaults
64
+ {
65
+ worker_classes: workers_from_env || []
66
+ }
67
+ end
68
+
69
+ def logger
70
+ @logger ||= default_logger
71
+ end
72
+
73
+ def runner
74
+ @runner ||= Runner.new(defaults)
75
+ end
76
+
77
+ def workers_from_env
78
+ if ENV['WORKERS']
79
+ ENV['WORKERS'].split(',').map(&:strip).map do |string|
80
+ string
81
+ .split('::')
82
+ .reduce(Object) { |a, e| a.const_get(e) }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end