mimi-messaging-sqs_sns 0.4.3 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: c519409be703d17780104c7128f1ac45356e6a28
4
- data.tar.gz: d79e3369b555015308c3e794bec2e6e5a7990c56
2
+ SHA256:
3
+ metadata.gz: d8ca2b61a4f7cc802b628397b74d45a19f81dd50cf7d285bfc813922e17e7327
4
+ data.tar.gz: fea7216f18b7707cedefaac1090349791c01f35b76f0a3019531039e28e8a39e
5
5
  SHA512:
6
- metadata.gz: 835a5d1103716fa79de1db5b4869650645ebfd472b8e80343741f5af0f666f12ab07bd16b35eb88b6c9a8d601364b2bcebba86f050e0b111fcec0a627ac631e9
7
- data.tar.gz: 6980d6feea3ed5c9e5af6a2a5735c05511b19dcc091e7ac0f62c37cfd7357689b4efdfaa7fbc443d266cfbed9746351d102db1c433d9e44cd30df5afa9861e56
6
+ metadata.gz: c4734aef7603919fe1485b1403fe63cfe3751b25ca0c753336b1567b524817ad68d9a0255fb14a993f365d58963b3a5a894bb02b338eac19d2af01303455510b
7
+ data.tar.gz: 017f5eb8d84cb0b10196c6a76fee01b5c1f3909c3b3eca2883e729992b02370f9be9827639858cbcb7e68beb02c35e4ac6cc9b5f40726b9e8faaccf5c8ee7eb6
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## UNRELEASED
4
+
5
+ ## v0.8.1
6
+
7
+ * [#5](https://github.com/kukushkin/mimi-messaging-sqs_sns/pull/5)
8
+ * Refactored worker pool based message processing and error handling
9
+
10
+ ## v0.8.0
11
+
12
+ * [#3](https://github.com/kukushkin/mimi-messaging-sqs_sns/pull/3)
13
+ * Added a worker pool:
14
+ * now processing of messages from a single queue can be done in multiple parallel threads (workers)
15
+ * the worker threads are shared across all message consumers which read messages from different queues
16
+ * the size of the worker pool is limited, to have at most `mq_worker_pool_max_threads` processing messages in parallel
17
+ * `mq_worker_pool_min_threads` determines the target minimal number of threads in the pool, waiting for new messages
18
+ * `mq_worker_pool_max_backlog` controls how many messages which are read from SQS queues can be put in the worker pool backlog; if a new message is read from SQS queue and the backlog is full, this message is NACK-ed (put back into SQS queue for the other consumers to process)
19
+ * Improved thread-safety of the adapter: reply consumer, TimeoutQueue#pop
20
+
21
+ ## v0.7.0
22
+
23
+ * [#1](https://github.com/kukushkin/mimi-messaging-sqs_sns/pull/1)
24
+ * Added KMS support for creating queues/topics with sever-side encryption enabled
25
+ * Optimized stopping of the adapter: stopping all consumers in parallel
26
+
27
+
28
+ ## v0.6.x
29
+
30
+ * Basic functionality implemented, see missing features in [TODO](TODO.md)
data/README.md CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  AWS SQS/SNS adapter for [mimi-messaging](https://github.com/kukushkin/mimi-messaging).
4
4
 
5
+ * [Changes](CHANGELOG.md)
6
+
7
+
5
8
  ## Installation
6
9
 
7
10
  Add this line to your application's Gemfile:
@@ -43,7 +46,6 @@ Mimi::Messaging.configure(
43
46
  Mimi::Messaging.start
44
47
  ```
45
48
 
46
-
47
49
  ## Contributing
48
50
 
49
51
  Bug reports and pull requests are welcome on GitHub at https://github.com/kukushkin/mimi-messaging-sqs_sns. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
data/TODO.md ADDED
@@ -0,0 +1,9 @@
1
+ # TODO
2
+
3
+ List of missing features planned for future releases.
4
+
5
+ * [X] Log error and recover if the reply cannot be sent
6
+ * [X] Threadsafe TimeoutQueue
7
+ * [X] Multithreaded consumers
8
+ * [ ] Subscribe to topic without a queue (Temporary queues)
9
+
data/examples/event.rb CHANGED
@@ -4,8 +4,8 @@ require "mimi/messaging/sqs_sns"
4
4
 
5
5
  COUNT = 10
6
6
  AWS_REGION = "eu-west-1"
7
- AWS_SQS_ENDPOINT_URL = "http://localstack:4576"
8
- AWS_SNS_ENDPOINT_URL = "http://localstack:4575"
7
+ AWS_SQS_ENDPOINT_URL = "http://localstack:4566"
8
+ AWS_SNS_ENDPOINT_URL = "http://localstack:4566"
9
9
  AWS_ACCESS_KEY_ID = "foo"
10
10
  AWS_SECRET_ACCESS_KEY = "bar"
11
11
 
@@ -30,5 +30,5 @@ COUNT.times do |i|
30
30
  t = Time.now
31
31
  puts "Publishing event: #{i}"
32
32
  adapter.event("hello#tested", i: i) # rand(100))
33
- sleep 1
33
+ sleep 0.1
34
34
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mimi/messaging/sqs_sns"
4
+
5
+ COUNT = 10
6
+ THREADS = 10
7
+ QUERY_TIMEOUT = 60
8
+ AWS_REGION = "eu-west-1"
9
+ AWS_SQS_ENDPOINT_URL = "http://localstack:4566"
10
+ AWS_SNS_ENDPOINT_URL = "http://localstack:4566"
11
+ AWS_ACCESS_KEY_ID = "foo"
12
+ AWS_SECRET_ACCESS_KEY = "bar"
13
+ AWS_SQS_SNS_KMS_MASTER_KEY_ID = "blah"
14
+
15
+ logger = Logger.new(STDOUT)
16
+ logger.level = Logger::INFO
17
+ Mimi::Messaging.use(logger: logger, serializer: Mimi::Messaging::JsonSerializer)
18
+ Mimi::Messaging.configure(
19
+ mq_adapter: "sqs_sns",
20
+ mq_aws_access_key_id: AWS_ACCESS_KEY_ID,
21
+ mq_aws_secret_access_key: AWS_SECRET_ACCESS_KEY,
22
+ mq_aws_region: AWS_REGION,
23
+ mq_aws_sqs_endpoint: AWS_SQS_ENDPOINT_URL,
24
+ mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL,
25
+ mq_aws_sqs_sns_kms_master_key_id: AWS_SQS_SNS_KMS_MASTER_KEY_ID,
26
+ mq_default_query_timeout: QUERY_TIMEOUT
27
+ )
28
+ adapter = Mimi::Messaging.adapter
29
+
30
+ adapter.start
31
+
32
+ t_start = Time.now
33
+ t_queries = []
34
+ threads = []
35
+ THREADS.times do |ti|
36
+ threads << Thread.new do
37
+ COUNT.times do |i|
38
+ t = Time.now
39
+ result = adapter.query("test/hello", i: i) # rand(100))
40
+ t = Time.now - t
41
+ t_queries << t
42
+ puts "result: #{result.to_h}, t: %.3fs" % t
43
+ sleep 0.1
44
+ end
45
+ end
46
+ end
47
+
48
+ threads.each(&:join)
49
+
50
+ t_elapsed = Time.now - t_start
51
+ puts "t_elapsed: %.3fs" % t_elapsed
52
+ adapter.stop
53
+ puts "t.avg: %.3fs" % (t_queries.sum / t_queries.count)
data/examples/query.rb CHANGED
@@ -4,10 +4,11 @@ require "mimi/messaging/sqs_sns"
4
4
 
5
5
  COUNT = 10
6
6
  AWS_REGION = "eu-west-1"
7
- AWS_SQS_ENDPOINT_URL = "http://localstack:4576"
8
- AWS_SNS_ENDPOINT_URL = "http://localstack:4575"
7
+ AWS_SQS_ENDPOINT_URL = "http://localstack:4566"
8
+ AWS_SNS_ENDPOINT_URL = "http://localstack:4566"
9
9
  AWS_ACCESS_KEY_ID = "foo"
10
10
  AWS_SECRET_ACCESS_KEY = "bar"
11
+ AWS_SQS_SNS_KMS_MASTER_KEY_ID = "blah"
11
12
 
12
13
  logger = Logger.new(STDOUT)
13
14
  logger.level = Logger::INFO
@@ -18,7 +19,8 @@ Mimi::Messaging.configure(
18
19
  mq_aws_secret_access_key: AWS_SECRET_ACCESS_KEY,
19
20
  mq_aws_region: AWS_REGION,
20
21
  mq_aws_sqs_endpoint: AWS_SQS_ENDPOINT_URL,
21
- mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL
22
+ mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL,
23
+ mq_aws_sqs_sns_kms_master_key_id: AWS_SQS_SNS_KMS_MASTER_KEY_ID
22
24
  )
23
25
  adapter = Mimi::Messaging.adapter
24
26
 
@@ -2,11 +2,12 @@
2
2
 
3
3
  require "mimi/messaging/sqs_sns"
4
4
 
5
- AWS_REGION = "eu-west-1"
6
- AWS_SQS_ENDPOINT_URL = "http://localstack:4576"
7
- AWS_SNS_ENDPOINT_URL = "http://localstack:4575"
8
- AWS_ACCESS_KEY_ID = "foo"
5
+ AWS_REGION = "eu-west-1"
6
+ AWS_SQS_ENDPOINT_URL = "http://localstack:4566"
7
+ AWS_SNS_ENDPOINT_URL = "http://localstack:4566"
8
+ AWS_ACCESS_KEY_ID = "foo"
9
9
  AWS_SECRET_ACCESS_KEY = "bar"
10
+ AWS_SQS_SNS_KMS_MASTER_KEY_ID = "blah"
10
11
 
11
12
  class Processor
12
13
  def self.call_command(method_name, message, opts)
@@ -16,13 +17,14 @@ class Processor
16
17
  def self.call_query(method_name, message, opts)
17
18
  # puts "QUERY: #{method_name}, #{message}, headers: #{opts[:headers]}"
18
19
  puts "QUERY: #{method_name}, headers: #{opts[:headers]}"
20
+ sleep 1 # imitate work
19
21
  {}
20
22
  end
21
23
  end # class Processor
22
24
 
23
25
 
24
26
  logger = Logger.new(STDOUT)
25
- logger.level = Logger::INFO
27
+ logger.level = Logger::DEBUG
26
28
  Mimi::Messaging.use(logger: logger, serializer: Mimi::Messaging::JsonSerializer)
27
29
  Mimi::Messaging.configure(
28
30
  mq_adapter: "sqs_sns",
@@ -30,7 +32,12 @@ Mimi::Messaging.configure(
30
32
  mq_aws_secret_access_key: AWS_SECRET_ACCESS_KEY,
31
33
  mq_aws_region: AWS_REGION,
32
34
  mq_aws_sqs_endpoint: AWS_SQS_ENDPOINT_URL,
33
- mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL
35
+ mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL,
36
+ mq_aws_sqs_sns_kms_master_key_id: AWS_SQS_SNS_KMS_MASTER_KEY_ID,
37
+ mq_worker_pool_min_threads: 1,
38
+ mq_worker_pool_max_threads: 2,
39
+ mq_worker_pool_max_backlog: 4,
40
+ mq_log_at_level: :info
34
41
  )
35
42
  adapter = Mimi::Messaging.adapter
36
43
  queue_name = "test"
@@ -47,4 +54,3 @@ ensure
47
54
  puts "Stopping adapter"
48
55
  adapter.stop
49
56
  end
50
-
@@ -3,8 +3,8 @@
3
3
  require "mimi/messaging/sqs_sns"
4
4
 
5
5
  AWS_REGION = "eu-west-1"
6
- AWS_SQS_ENDPOINT_URL = "http://localstack:4576"
7
- AWS_SNS_ENDPOINT_URL = "http://localstack:4575"
6
+ AWS_SQS_ENDPOINT_URL = "http://localstack:4566"
7
+ AWS_SNS_ENDPOINT_URL = "http://localstack:4566"
8
8
  AWS_ACCESS_KEY_ID = "foo"
9
9
  AWS_SECRET_ACCESS_KEY = "bar"
10
10
 
@@ -20,6 +20,7 @@ class Processor
20
20
 
21
21
  def self.call_event(event_type, message, opts)
22
22
  puts "EVENT: #{event_type}, #{message}, headers: #{message.headers}"
23
+ sleep 1 # imitate work
23
24
  end
24
25
  end # class Processor
25
26
 
@@ -34,6 +35,9 @@ Mimi::Messaging.configure(
34
35
  mq_aws_region: AWS_REGION,
35
36
  mq_aws_sqs_endpoint: AWS_SQS_ENDPOINT_URL,
36
37
  mq_aws_sns_endpoint: AWS_SNS_ENDPOINT_URL,
38
+ mq_worker_pool_min_threads: 1,
39
+ mq_worker_pool_max_threads: 2,
40
+ mq_worker_pool_max_backlog: 4,
37
41
  mq_log_at_level: :info
38
42
  )
39
43
  adapter = Mimi::Messaging.adapter
@@ -52,4 +56,3 @@ ensure
52
56
  puts "Stopping adapter"
53
57
  adapter.stop
54
58
  end
55
-
@@ -12,4 +12,5 @@ end # module Mimi
12
12
 
13
13
  require_relative "sqs_sns/adapter"
14
14
  require_relative "sqs_sns/consumer"
15
+ require_relative "sqs_sns/temporary_queue_consumer"
15
16
  require_relative "sqs_sns/reply_consumer"
@@ -3,8 +3,9 @@
3
3
  require "mimi/messaging"
4
4
  require "aws-sdk-sqs"
5
5
  require "aws-sdk-sns"
6
- require "timeout"
7
6
  require "securerandom"
7
+ require "concurrent"
8
+ require_relative "timeout_queue"
8
9
 
9
10
  module Mimi
10
11
  module Messaging
@@ -40,7 +41,7 @@ module Mimi
40
41
  "." => "-"
41
42
  }.freeze
42
43
 
43
- attr_reader :options, :sqs_client, :sns_client
44
+ attr_reader :options, :sqs_client, :sns_client, :worker_pool
44
45
 
45
46
  register_adapter_name "sqs_sns"
46
47
 
@@ -49,6 +50,11 @@ module Mimi
49
50
  mq_default_query_timeout: 15, # seconds,
50
51
  mq_reply_queue_prefix: "reply-",
51
52
 
53
+ # worker pool parameters
54
+ mq_worker_pool_min_threads: 1,
55
+ mq_worker_pool_max_threads: 16,
56
+ mq_worker_pool_max_backlog: 16,
57
+
52
58
  # if nil, AWS SDK will guess values from environment
53
59
  mq_aws_region: nil,
54
60
  mq_aws_access_key_id: nil,
@@ -56,7 +62,8 @@ module Mimi
56
62
  mq_aws_sqs_endpoint: nil,
57
63
  mq_aws_sns_endpoint: nil,
58
64
 
59
- mq_aws_sqs_read_timeout: 10, # seconds
65
+ mq_aws_sqs_sns_kms_master_key_id: nil,
66
+ mq_aws_sqs_read_timeout: 20, # seconds
60
67
  }.freeze
61
68
 
62
69
  # Initializes SQS/SNS adapter
@@ -73,16 +80,19 @@ module Mimi
73
80
  #
74
81
  def initialize(options)
75
82
  @options = DEFAULT_OPTIONS.merge(options).dup
83
+ @reply_consumer_mutex = Mutex.new
76
84
  end
77
85
 
78
86
  def start
79
87
  @sqs_client = Aws::SQS::Client.new(sqs_client_config)
80
88
  @sns_client = Aws::SNS::Client.new(sns_client_config)
89
+ start_worker_pool!
81
90
  check_availability!
82
91
  end
83
92
 
84
93
  def stop
85
94
  stop_all_processors
95
+ stop_worker_pool!
86
96
  @sqs_client = nil
87
97
  @sns_client = nil
88
98
  end
@@ -93,6 +103,7 @@ module Mimi
93
103
  # for processors.
94
104
  #
95
105
  def stop_all_processors
106
+ @consumers&.each(&:signal_stop)
96
107
  @consumers&.each(&:stop)
97
108
  @consumers = nil
98
109
  @reply_consumer&.stop
@@ -124,7 +135,7 @@ module Mimi
124
135
  # @param opts [Hash] additional options, e.g. :timeout
125
136
  #
126
137
  # @return [Hash]
127
- # @raise [SomeError,TimeoutError]
138
+ # @raise [SomeError,Timeout::Error]
128
139
  #
129
140
  def query(target, message, opts = {})
130
141
  queue_name, method_name = target.split("/")
@@ -140,10 +151,7 @@ module Mimi
140
151
  )
141
152
  deliver_message_queue(queue_url, message)
142
153
  timeout = opts[:timeout] || options[:mq_default_query_timeout]
143
- response = nil
144
- Timeout::timeout(timeout) do
145
- response = reply_queue.pop
146
- end
154
+ response = reply_queue.pop(true, timeout)
147
155
  deserialize(response.body)
148
156
  end
149
157
 
@@ -178,22 +186,7 @@ module Mimi
178
186
  opts = opts.dup
179
187
  queue_url = find_or_create_queue(queue_name)
180
188
  @consumers << Consumer.new(self, queue_url) do |m|
181
- message = Mimi::Messaging::Message.new(
182
- deserialize(m.body),
183
- deserialize_headers(m)
184
- )
185
- method_name = message.headers[:__method]
186
- reply_to = message.headers[:__reply_queue_url]
187
- if reply_to
188
- response = processor.call_query(method_name, message, {})
189
- response_message = Mimi::Messaging::Message.new(
190
- response,
191
- __request_id: message.headers[:__request_id]
192
- )
193
- deliver_message_queue(reply_to, response_message)
194
- else
195
- processor.call_command(method_name, message, {})
196
- end
189
+ process_request_message(processor, m)
197
190
  end
198
191
  end
199
192
 
@@ -210,15 +203,52 @@ module Mimi
210
203
  queue_url = find_or_create_queue(queue_name)
211
204
  subscribe_topic_queue(topic_arn, queue_url)
212
205
  @consumers << Consumer.new(self, queue_url) do |m|
213
- message = Mimi::Messaging::Message.new(
214
- deserialize(m.body),
215
- deserialize_headers(m)
216
- )
217
- event_type = message.headers[:__event_type]
218
- processor.call_event(event_type, message, {})
206
+ process_event_message(processor, m)
219
207
  end
220
208
  end
221
209
 
210
+ # Creates a new queue
211
+ #
212
+ # @param queue_name [String] name of the topic to be created
213
+ # @return [String] a new queue URL
214
+ #
215
+ def create_queue(queue_name)
216
+ fqn = sqs_sns_converted_full_name(queue_name)
217
+ Mimi::Messaging.log "Creating a queue: #{fqn}"
218
+ attrs = {}
219
+ if options[:mq_aws_sqs_sns_kms_master_key_id]
220
+ attrs["KmsMasterKeyId"] = options[:mq_aws_sqs_sns_kms_master_key_id]
221
+ end
222
+ result = sqs_client.create_queue(queue_name: fqn, attributes: attrs)
223
+ result.queue_url
224
+ rescue StandardError => e
225
+ raise Mimi::Messaging::ConnectionError, "Failed to create queue '#{queue_name}': #{e}"
226
+ end
227
+
228
+ # Finds a queue URL for a queue with given name.
229
+ #
230
+ # If an existing queue with this name is not found,
231
+ # the method will try to create a new one.
232
+ #
233
+ # @param queue_name [String]
234
+ # @return [String] a queue URL
235
+ #
236
+ def find_or_create_queue(queue_name)
237
+ queue_registry(queue_name) || create_queue(queue_name)
238
+ end
239
+
240
+ # Deletes a queue identified by the queue URL
241
+ #
242
+ # @param queue_url [String]
243
+ #
244
+ def delete_queue(queue_url)
245
+ Mimi::Messaging.log "Deleting a queue: #{queue_url}"
246
+ sqs_client.delete_queue(queue_url: queue_url)
247
+ rescue StandardError => e
248
+ raise Mimi::Messaging::ConnectionError,
249
+ "Failed to delete queue with url '#{queue_url}': #{e}"
250
+ end
251
+
222
252
  private
223
253
 
224
254
  # Returns configuration parameters for AWS SQS client
@@ -266,20 +296,6 @@ module Mimi
266
296
  end
267
297
  end
268
298
 
269
- # Creates a new queue
270
- #
271
- # @param queue_name [String] name of the topic to be created
272
- # @return [String] a new queue URL
273
- #
274
- def create_queue(queue_name)
275
- fqn = sqs_sns_converted_full_name(queue_name)
276
- Mimi::Messaging.log "Creating a queue: #{fqn}"
277
- result = sqs_client.create_queue(queue_name: fqn)
278
- result.queue_url
279
- rescue StandardError => e
280
- raise Mimi::Messaging::ConnectionError, "Failed to create queue '#{queue_name}': #{e}"
281
- end
282
-
283
299
  # Delivers a message to a queue with given URL.
284
300
  #
285
301
  # @param queue_url [String]
@@ -290,6 +306,7 @@ module Mimi
290
306
  unless message.is_a?(Mimi::Messaging::Message)
291
307
  raise ArgumentError, "Message is expected as argument"
292
308
  end
309
+
293
310
  Mimi::Messaging.log "Delivering message to: #{queue_url}, headers: #{message.headers}"
294
311
  sqs_client.send_message(
295
312
  queue_url: queue_url,
@@ -302,6 +319,47 @@ module Mimi
302
319
  raise Mimi::Messaging::ConnectionError, "Failed to deliver message to '#{queue_url}': #{e}"
303
320
  end
304
321
 
322
+ # Processes an incoming COMMAND or QUERY message
323
+ #
324
+ # @param processor [#call_query(),#call_command()] request processor object
325
+ # @param sqs_message
326
+ #
327
+ def process_request_message(processor, sqs_message)
328
+ message = Mimi::Messaging::Message.new(
329
+ deserialize(sqs_message.body),
330
+ deserialize_headers(sqs_message)
331
+ )
332
+ method_name = message.headers[:__method]
333
+ reply_to = message.headers[:__reply_queue_url]
334
+ if reply_to
335
+ response = processor.call_query(method_name, message, {})
336
+ response_message = Mimi::Messaging::Message.new(
337
+ response,
338
+ __request_id: message.headers[:__request_id]
339
+ )
340
+ deliver_query_response(reply_to, response_message)
341
+ else
342
+ processor.call_command(method_name, message, {})
343
+ end
344
+ end
345
+
346
+ # Delivers a message as a response to a QUERY
347
+ #
348
+ # Responses are allowed to fail. There can be a number of reasons
349
+ # why responses fail: reply queue does not exist (anymore?),
350
+ # response message is too big. In any case the error is reported,
351
+ # but the QUERY message is acknowledged as a successfully processed.
352
+ #
353
+ # @param queue_url [String]
354
+ # @param message [Mimi::Messaging::Message]
355
+ #
356
+ def deliver_query_response(queue_url, message)
357
+ deliver_message_queue(queue_url, message)
358
+ rescue Mimi::Messaging::ConnectionError => e
359
+ Mimi::Messaging.logger&.warn("Failed to deliver QRY response: #{e}")
360
+ # NOTE: error is recovered
361
+ end
362
+
305
363
  # Returns URL of a queue with a given name.
306
364
  #
307
365
  # If the queue with given name does not exist, returns nil
@@ -349,27 +407,16 @@ module Mimi
349
407
  )
350
408
  end
351
409
 
352
- # Finds a queue URL for a queue with given name.
353
- #
354
- # If an existing queue with this name is not found,
355
- # the method will try to create a new one.
356
- #
357
- # @param queue_name [String]
358
- # @return [String] a queue URL
359
- #
360
- def find_or_create_queue(queue_name)
361
- queue_registry(queue_name) || create_queue(queue_name)
362
- end
363
-
364
410
  # Returns the configured reply listener for this process
365
411
  #
366
412
  # @return [ReplyConsumer]
367
413
  #
368
414
  def reply_consumer
369
- @reply_consumer ||= begin
370
- reply_queue_name = options[:mq_reply_queue_prefix] + SecureRandom.hex(8)
371
- reply_queue_url = create_queue(reply_queue_name)
372
- Mimi::Messaging::SQS_SNS::ReplyConsumer.new(self, reply_queue_url)
415
+ @reply_consumer_mutex.synchronize do
416
+ @reply_consumer ||= begin
417
+ reply_queue_name = options[:mq_reply_queue_prefix] + SecureRandom.hex(8)
418
+ Mimi::Messaging::SQS_SNS::ReplyConsumer.new(self, reply_queue_name)
419
+ end
373
420
  end
374
421
  end
375
422
 
@@ -450,7 +497,11 @@ module Mimi
450
497
  def create_topic(topic_name)
451
498
  fqn = sqs_sns_converted_full_name(topic_name)
452
499
  Mimi::Messaging.log "Creating a topic: #{fqn}"
453
- result = sns_client.create_topic(name: fqn)
500
+ attrs = {}
501
+ if options[:mq_aws_sqs_sns_kms_master_key_id]
502
+ attrs["KmsMasterKeyId"] = options[:mq_aws_sqs_sns_kms_master_key_id]
503
+ end
504
+ result = sns_client.create_topic(name: fqn, attributes: attrs)
454
505
  result.topic_arn
455
506
  rescue StandardError => e
456
507
  raise Mimi::Messaging::ConnectionError, "Failed to create topic '#{topic_name}': #{e}"
@@ -467,7 +518,7 @@ module Mimi
467
518
  )
468
519
  queue_arn = result.attributes["QueueArn"]
469
520
  Mimi::Messaging.log "Subscribing queue to a topic: '#{topic_arn}'->'#{queue_url}'"
470
- result = sns_client.subscribe(
521
+ _result = sns_client.subscribe(
471
522
  topic_arn: topic_arn,
472
523
  protocol: "sqs",
473
524
  endpoint: queue_arn,
@@ -489,6 +540,7 @@ module Mimi
489
540
  unless message.is_a?(Mimi::Messaging::Message)
490
541
  raise ArgumentError, "Message is expected as argument"
491
542
  end
543
+
492
544
  Mimi::Messaging.log "Delivering message to: #{topic_arn}"
493
545
  sns_client.publish(
494
546
  topic_arn: topic_arn,
@@ -500,6 +552,46 @@ module Mimi
500
552
  rescue StandardError => e
501
553
  raise Mimi::Messaging::ConnectionError, "Failed to deliver message to '#{topic_arn}': #{e}"
502
554
  end
555
+
556
+ # Processes an incoming EVENT message
557
+ #
558
+ # @param processor [#call_event()] event processor object
559
+ # @param sqs_message []
560
+ #
561
+ def process_event_message(processor, sqs_message)
562
+ message = Mimi::Messaging::Message.new(
563
+ deserialize(sqs_message.body),
564
+ deserialize_headers(sqs_message)
565
+ )
566
+ event_type = message.headers[:__event_type]
567
+ processor.call_event(event_type, message, {})
568
+ end
569
+
570
+ # Starts the worker pool using current configuration
571
+ #
572
+ # @return [Concurrent::ThreadPoolExecutor]
573
+ #
574
+ def start_worker_pool!
575
+ Mimi::Messaging.log "Starting worker pool, " \
576
+ "min_threads:#{options[:mq_worker_pool_min_threads]}, " \
577
+ "max_threads:#{options[:mq_worker_pool_max_threads]}, " \
578
+ "max_backlog:#{options[:mq_worker_pool_max_backlog]}"
579
+
580
+ @worker_pool = Concurrent::ThreadPoolExecutor.new(
581
+ min_threads: options[:mq_worker_pool_min_threads],
582
+ max_threads: options[:mq_worker_pool_max_threads],
583
+ max_queue: options[:mq_worker_pool_max_backlog],
584
+ fallback_policy: :abort
585
+ )
586
+ end
587
+
588
+ # Gracefully stops the worker pool, allowing all threads to finish their jobs
589
+ #
590
+ def stop_worker_pool!
591
+ Mimi::Messaging.log "Stopping worker pool"
592
+ @worker_pool.shutdown
593
+ @worker_pool.wait_for_termination
594
+ end
503
595
  end # class Adapter
504
596
  end # module SQS_SNS
505
597
  end # module Messaging
@@ -4,25 +4,31 @@ module Mimi
4
4
  module Messaging
5
5
  module SQS_SNS
6
6
  #
7
- # Message processor for SQS queues
7
+ # Message consumer for SQS queues
8
8
  #
9
9
  class Consumer
10
+ # (seconds) determines how soon the NACK-ed message becomes visible to other consumers
11
+ NACK_VISIBILITY_TIMEOUT = 1
12
+
10
13
  def initialize(adapter, queue_url, &block)
11
- @block = block
12
14
  @stop_requested = false
13
15
  Mimi::Messaging.log "Starting consumer for: #{queue_url}"
14
16
  @consumer_thread = Thread.new do
15
- while not @stop_requested do
16
- message = read_message(adapter, queue_url)
17
- next unless message
18
- Mimi::Messaging.log "Read message from: #{queue_url}"
19
- block.call(message)
20
- ack_message(adapter, queue_url, message)
17
+ while not @stop_requested
18
+ read_and_process_message(adapter, queue_url, block)
21
19
  end
22
20
  Mimi::Messaging.log "Stopping consumer for: #{queue_url}"
23
21
  end
24
22
  end
25
23
 
24
+ # Requests the Consumer to stop, without actually waiting for it
25
+ #
26
+ def signal_stop
27
+ @stop_requested = true
28
+ end
29
+
30
+ # Requests the Consumer to stop AND waits until it does
31
+ #
26
32
  def stop
27
33
  @stop_requested = true
28
34
  @consumer_thread.join
@@ -35,16 +41,24 @@ module Mimi
35
41
  #
36
42
  # @param adapter [Mimi::Messaging::SQS_SNS::Adapter]
37
43
  # @param queue_url [String]
44
+ # @param block [Proc] a block to be invoked when a message is received
38
45
  #
39
- def read_and_process_message(adapter, queue_url)
46
+ def read_and_process_message(adapter, queue_url, block)
40
47
  message = read_message(adapter, queue_url)
41
48
  return unless message
49
+
42
50
  Mimi::Messaging.log "Read message from: #{queue_url}"
43
- block.call(message)
44
- ack_message(adapter, queue_url, message)
51
+ adapter.worker_pool.post do
52
+ process_message(adapter, queue_url, message, block)
53
+ end
54
+ rescue Concurrent::RejectedExecutionError
55
+ # the backlog is overflown, put the message back
56
+ Mimi::Messaging.log "Worker pool backlog is full, nack-ing the message " \
57
+ "(workers:#{adapter.worker_pool.length}, backlog:#{adapter.worker_pool.queue_length})"
58
+ nack_message(adapter, queue_url, message)
45
59
  rescue StandardError => e
46
60
  Mimi::Messaging.logger&.error(
47
- "#{self.class}: failed to read and process message from: #{queue_url}," \
61
+ "#{self.class}: failed to read or process message from: #{queue_url}," \
48
62
  " error: (#{e.class}) #{e}"
49
63
  )
50
64
  end
@@ -58,14 +72,43 @@ module Mimi
58
72
  )
59
73
  return nil if result.messages.count == 0
60
74
  return result.messages.first if result.messages.count == 1
75
+
61
76
  raise Mimi::Messaging::ConnectionError, "Unexpected number of messages read"
62
77
  end
63
78
 
79
+ def process_message(adapter, queue_url, message, block)
80
+ block.call(message)
81
+ ack_message(adapter, queue_url, message)
82
+ rescue Mimi::Messaging::NACK
83
+ Mimi::Messaging.log "NACK-ing message from: #{queue_url}"
84
+ nack_message(adapter, queue_url, message)
85
+ rescue StandardError => e
86
+ Mimi::Messaging.logger&.error(
87
+ "#{self.class}: failed to process message from: #{queue_url}," \
88
+ " error: (#{e.class}) #{e}"
89
+ )
90
+ # NOTE: message is neither ACKed or NACKed
91
+ end
92
+
93
+ # ACK-ing the message indicates successfull processing of it
94
+ # and removes the message from the queue
95
+ #
64
96
  def ack_message(adapter, queue_url, msg)
65
97
  adapter.sqs_client.delete_message(
66
98
  queue_url: queue_url, receipt_handle: msg.receipt_handle
67
99
  )
68
100
  end
101
+
102
+ # NACK-ing the message indicates a failure to process the message.
103
+ # The message becomes immediately available to other consumers.
104
+ #
105
+ def nack_message(adapter, queue_url, msg)
106
+ adapter.sqs_client.change_message_visibility(
107
+ queue_url: queue_url,
108
+ receipt_handle: msg.receipt_handle,
109
+ visibility_timeout: NACK_VISIBILITY_TIMEOUT
110
+ )
111
+ end
69
112
  end # class Consumer
70
113
  end # module SQS_SNS
71
114
  end # module Messaging
@@ -8,25 +8,23 @@ module Mimi
8
8
  # and passes them to registered Queues (see Ruby ::Queue class).
9
9
  #
10
10
  class ReplyConsumer
11
- attr_reader :reply_queue_url
11
+ attr_reader :reply_queue_name, :reply_queue_url
12
12
 
13
- def initialize(adapter, reply_queue_url)
13
+ def initialize(adapter, reply_queue_name)
14
14
  @mutex = Mutex.new
15
15
  @queues = {}
16
16
  @adapter = adapter
17
- @reply_queue_url = reply_queue_url
18
- @consumer = Consumer.new(adapter, reply_queue_url) do |message|
17
+ @reply_queue_name = reply_queue_name
18
+ @consumer = TemporaryQueueConsumer.new(adapter, reply_queue_name) do |message|
19
19
  dispatch_message(message)
20
20
  end
21
+ @reply_queue_url = @consumer.queue_url
21
22
  end
22
23
 
23
24
  def stop
24
- begin
25
- @consumer.stop
26
- rescue StandardError => e
27
- raise Mimi::Messaging::Error, "Failed to stop consumer: #{e}"
28
- end
29
- # TODO: adapter.sqs_client.delete_queue(reply_queue_url)
25
+ @consumer.stop
26
+ rescue StandardError => e
27
+ raise Mimi::Messaging::Error, "Failed to stop reply consumer: #{e}"
30
28
  end
31
29
 
32
30
  # Register a new request_id to listen for.
@@ -38,7 +36,7 @@ module Mimi
38
36
  # @return [Queue] a new Queue object registered for this request_id
39
37
  #
40
38
  def register_request_id(request_id)
41
- queue = Queue.new
39
+ queue = TimeoutQueue.new
42
40
  @mutex.synchronize do
43
41
  queue = @queues[request_id] ||= queue
44
42
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mimi
4
+ module Messaging
5
+ module SQS_SNS
6
+ #
7
+ # Temporary queue consumer creates a temporary queue
8
+ # and attaches to it. The queue will be deleted
9
+ # on consumer shutdown.
10
+ #
11
+ class TemporaryQueueConsumer
12
+ attr_reader :queue_url
13
+
14
+ def initialize(adapter, queue_name, &block)
15
+ @adapter = adapter
16
+ @queue_url = adapter.find_or_create_queue(queue_name)
17
+ @consumer = Consumer.new(adapter, @queue_url, &block)
18
+ end
19
+
20
+ def stop
21
+ @consumer.stop
22
+ @adapter.delete_queue(queue_url)
23
+ rescue StandardError => e
24
+ raise Mimi::Messaging::Error, "Failed to stop temporary queue consumer: #{e}"
25
+ end
26
+ end # class TemporaryQueueConsumer
27
+ end # module SQS_SNS
28
+ end # module Messaging
29
+ end # module Mimi
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Mimi
6
+ module Messaging
7
+ module SQS_SNS
8
+ # TimeoutQueue solves the problem the native Ruby Queue class has with waiting for elements.
9
+ #
10
+ # See the excellent blog post discussing the issue:
11
+ # https://medium.com/workday-engineering/ruby-concurrency-building-a-timeout-queue-5d7c588ca80d
12
+ #
13
+ # TLDR -- using Ruby standard Timeout.timeout() around Queue#pop() is unsafe
14
+ #
15
+ #
16
+ class TimeoutQueue
17
+ def initialize
18
+ @elems = []
19
+ @mutex = Mutex.new
20
+ @cond_var = ConditionVariable.new
21
+ end
22
+
23
+ # Pushes an element into the queue
24
+ #
25
+ # @param elem [Object]
26
+ #
27
+ def <<(elem)
28
+ @mutex.synchronize do
29
+ @elems << elem
30
+ @cond_var.signal
31
+ end
32
+ end
33
+ alias push <<
34
+
35
+ # Pops an element from the queue in either non-blocking
36
+ # or a blocking (with an optional timeout) way.
37
+ #
38
+ # @param blocking [true,false] wait for a new element (true) or return immediately
39
+ # @param timeout [nil,Integer] if in blocking mode,
40
+ # wait at most given number of seconds or forever (nil)
41
+ # @raise [Timeout::Error] if a timeout in blocking mode was reached
42
+ #
43
+ def pop(blocking = true, timeout = nil)
44
+ @mutex.synchronize do
45
+ if blocking
46
+ if timeout.nil?
47
+ while @elems.empty?
48
+ @cond_var.wait(@mutex)
49
+ end
50
+ else
51
+ timeout_time = Time.now.to_f + timeout
52
+ while @elems.empty? && (remaining_time = timeout_time - Time.now.to_f) > 0
53
+ @cond_var.wait(@mutex, remaining_time)
54
+ end
55
+ end
56
+ end
57
+ raise Timeout::Error, "queue timeout expired" if @elems.empty?
58
+
59
+ @elems.shift
60
+ end
61
+ end
62
+ end # class TimeoutQueue
63
+ end # module SQS_SNS
64
+ end # module Messaging
65
+ end # module Mimi
@@ -3,7 +3,7 @@
3
3
  module Mimi
4
4
  module Messaging
5
5
  module SQS_SNS
6
- VERSION = "0.4.3"
6
+ VERSION = "0.8.1"
7
7
  end
8
8
  end
9
9
  end
@@ -36,9 +36,10 @@ Gem::Specification.new do |spec|
36
36
  spec.add_dependency "mimi-messaging", "~> 1.2"
37
37
  spec.add_dependency "aws-sdk-sqs", "~> 1.22"
38
38
  spec.add_dependency "aws-sdk-sns", "~> 1.19"
39
+ spec.add_dependency "concurrent-ruby"
39
40
 
40
41
  spec.add_development_dependency "bundler", "~> 2.0"
41
42
  spec.add_development_dependency "pry", "~> 0.12"
42
- spec.add_development_dependency "rake", "~> 10.0"
43
+ spec.add_development_dependency "rake", "~> 12.0", ">= 12.3.3"
43
44
  spec.add_development_dependency "rspec", "~> 3.0"
44
45
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mimi-messaging-sqs_sns
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Kukushkin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-10-21 00:00:00.000000000 Z
11
+ date: 2021-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mimi-messaging
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.19'
55
+ - !ruby/object:Gem::Dependency
56
+ name: concurrent-ruby
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: bundler
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +100,20 @@ dependencies:
86
100
  requirements:
87
101
  - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: '10.0'
103
+ version: '12.0'
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: 12.3.3
90
107
  type: :development
91
108
  prerelease: false
92
109
  version_requirements: !ruby/object:Gem::Requirement
93
110
  requirements:
94
111
  - - "~>"
95
112
  - !ruby/object:Gem::Version
96
- version: '10.0'
113
+ version: '12.0'
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 12.3.3
97
117
  - !ruby/object:Gem::Dependency
98
118
  name: rspec
99
119
  requirement: !ruby/object:Gem::Requirement
@@ -118,14 +138,17 @@ files:
118
138
  - ".gitignore"
119
139
  - ".rspec"
120
140
  - ".travis.yml"
141
+ - CHANGELOG.md
121
142
  - CODE_OF_CONDUCT.md
122
143
  - Gemfile
123
144
  - LICENSE.txt
124
145
  - README.md
125
146
  - Rakefile
147
+ - TODO.md
126
148
  - bin/console
127
149
  - bin/setup
128
150
  - examples/event.rb
151
+ - examples/mass-query.rb
129
152
  - examples/query.rb
130
153
  - examples/responder.rb
131
154
  - examples/subscriber.rb
@@ -133,6 +156,8 @@ files:
133
156
  - lib/mimi/messaging/sqs_sns/adapter.rb
134
157
  - lib/mimi/messaging/sqs_sns/consumer.rb
135
158
  - lib/mimi/messaging/sqs_sns/reply_consumer.rb
159
+ - lib/mimi/messaging/sqs_sns/temporary_queue_consumer.rb
160
+ - lib/mimi/messaging/sqs_sns/timeout_queue.rb
136
161
  - lib/mimi/messaging/sqs_sns/version.rb
137
162
  - mimi-messaging-sqs_sns.gemspec
138
163
  homepage: https://github.com/kukushkin/mimi-messaging-sqs_sns
@@ -157,8 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
157
182
  - !ruby/object:Gem::Version
158
183
  version: '0'
159
184
  requirements: []
160
- rubyforge_project:
161
- rubygems_version: 2.6.14.4
185
+ rubygems_version: 3.1.2
162
186
  signing_key:
163
187
  specification_version: 4
164
188
  summary: AWS SQS/SNS adapter for mimi-messaging