circuitry 1.4.1 → 2.0.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,70 @@
1
+ require 'circuitry/provisioner'
2
+ require 'thor'
3
+
4
+ module Circuitry
5
+ class CLI < Thor
6
+ class_option :verbose, aliases: :v, type: :boolean
7
+
8
+ desc 'provision <queue> -t <topic> [<topic> ...]', 'Provision a queue subscribed to one or more topics'
9
+
10
+ long_desc <<-END
11
+ Creates an SQS queue with appropriate SNS access policy along with one or more SNS topics
12
+ named <topic> that has an SQS subscription for each.
13
+
14
+ When the queue already exists, its policy will be added or updated.
15
+
16
+ When a topic already exists, it will be ignored.
17
+
18
+ When a topic subscription already exists, it will be ignored.
19
+
20
+ If no dead letter queue is specified, one will be created by default with the
21
+ name <queue>-failures
22
+ END
23
+
24
+ option :topics, aliases: :t, type: :array, required: :true
25
+ option :access_key, aliases: :a
26
+ option :secret_key, aliases: :s
27
+ option :dead_letter_queue, aliases: :d
28
+ option :region, aliases: :r
29
+
30
+ def provision(queue_name)
31
+ with_custom_config(queue_name) do |config|
32
+ logger = Logger.new(STDOUT)
33
+ logger.level = Logger::INFO if options['verbose']
34
+ Circuitry::Provisioner.new(config, logger: logger).run
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def say(*args)
41
+ puts(*args) if options['verbose']
42
+ end
43
+
44
+ def with_custom_config(queue_name, &block)
45
+ original_values = {}
46
+ %i[access_key secret_key region subscriber_queue_name subscriber_dead_letter_queue_name publisher_topic_names].each do |sym|
47
+ original_values[sym] = Circuitry.config.send(sym)
48
+ end
49
+
50
+ assign_options_config(queue_name, original_values)
51
+
52
+ block.call(Circuitry.config)
53
+ ensure
54
+ restore_config(original_values)
55
+ end
56
+
57
+ def assign_options_config(queue_name, original_values)
58
+ Circuitry.config.access_key = options.fetch('access_key', original_values[:access_key])
59
+ Circuitry.config.secret_key = options.fetch('secret_key', original_values[:secret_key])
60
+ Circuitry.config.region = options.fetch('region', original_values[:region])
61
+ Circuitry.config.subscriber_queue_name = queue_name
62
+ Circuitry.config.subscriber_dead_letter_queue_name = options.fetch('dead_letter_queue', "#{queue_name}-failures")
63
+ Circuitry.config.publisher_topic_names = options['topics']
64
+ end
65
+
66
+ def restore_config(original_values)
67
+ original_values.keys.each { |key| Circuitry.config.send(:"#{key}=", original_values[key]) }
68
+ end
69
+ end
70
+ end
@@ -29,11 +29,11 @@ module Circuitry
29
29
 
30
30
  def async=(value)
31
31
  value = case value
32
- when false, nil then false
33
- when true then self.class.default_async_strategy
34
- when *self.class.async_strategies then value
35
- else raise ArgumentError, "Invalid value `#{value.inspect}`, must be one of #{[true, false].concat(self.class.async_strategies).inspect}"
36
- end
32
+ when false, nil then false
33
+ when true then self.class.default_async_strategy
34
+ when *self.class.async_strategies then value
35
+ else raise ArgumentError, async_value_error(value)
36
+ end
37
37
 
38
38
  if value == :fork && !platform_supports_forking?
39
39
  raise NotSupportedError, 'Your platform does not support forking'
@@ -43,11 +43,16 @@ module Circuitry
43
43
  end
44
44
 
45
45
  def async?
46
- !!async
46
+ ![nil, false].include?(async)
47
47
  end
48
48
 
49
49
  private
50
50
 
51
+ def async_value_error(value)
52
+ options = [true, false].concat(self.class.async_strategies).inspect
53
+ "Invalid value `#{value.inspect}`, must be one of #{options}"
54
+ end
55
+
51
56
  def platform_supports_forking?
52
57
  Process.respond_to?(:fork)
53
58
  end
@@ -5,14 +5,18 @@ module Circuitry
5
5
  class Configuration
6
6
  include Virtus::Model
7
7
 
8
+ attribute :subscriber_queue_name, String
9
+ attribute :subscriber_dead_letter_queue_name, String
10
+ attribute :publisher_topic_names, Array[String]
11
+
8
12
  attribute :access_key, String
9
13
  attribute :secret_key, String
10
14
  attribute :region, String, default: 'us-east-1'
11
15
  attribute :logger, Logger, default: Logger.new(STDERR)
12
16
  attribute :error_handler
13
- attribute :lock_strategy, Object, default: ->(page, attribute) { Circuitry::Locks::Memory.new }
14
- attribute :publish_async_strategy, Symbol, default: ->(page, attribute) { :fork }
15
- attribute :subscribe_async_strategy, Symbol, default: ->(page, attribute) { :fork }
17
+ attribute :lock_strategy, Object, default: ->(_page, _attribute) { Circuitry::Locks::Memory.new }
18
+ attribute :publish_async_strategy, Symbol, default: ->(_page, _attribute) { :fork }
19
+ attribute :subscribe_async_strategy, Symbol, default: ->(_page, _attribute) { :fork }
16
20
  attribute :on_thread_exit
17
21
  attribute :on_fork_exit
18
22
 
@@ -26,20 +30,23 @@ module Circuitry
26
30
  super
27
31
  end
28
32
 
33
+ def subscriber_dead_letter_queue_name
34
+ super || "#{subscriber_queue_name}-failures"
35
+ end
36
+
29
37
  def aws_options
30
38
  {
31
- access_key_id: access_key,
32
- secret_access_key: secret_key,
33
- region: region,
39
+ access_key_id: access_key,
40
+ secret_access_key: secret_key,
41
+ region: region
34
42
  }
35
43
  end
36
44
 
37
45
  private
38
46
 
39
47
  def validate(value, permitted_values)
40
- unless permitted_values.include?(value)
41
- raise ArgumentError, "invalid value `#{value}`, must be one of #{permitted_values.inspect}"
42
- end
48
+ return if permitted_values.include?(value)
49
+ raise ArgumentError, "invalid value `#{value}`, must be one of #{permitted_values.inspect}"
43
50
  end
44
51
  end
45
52
  end
@@ -0,0 +1,59 @@
1
+ require 'circuitry/queue_creator'
2
+ require 'circuitry/topic_creator'
3
+ require 'circuitry/subscription_creator'
4
+
5
+ module Circuitry
6
+ class Provisioner
7
+ attr_reader :log
8
+ attr_reader :config
9
+
10
+ def initialize(config, logger: Logger.new(STDOUT))
11
+ @config = config
12
+ @log = logger
13
+ end
14
+
15
+ def run
16
+ queue = create_queue
17
+ topics = create_topics if queue
18
+ subscribe_topics(queue, topics) if queue && topics
19
+ end
20
+
21
+ private
22
+
23
+ def create_queue
24
+ safe_aws('Create Queue') do
25
+ queue = Circuitry::QueueCreator.find_or_create(
26
+ config.subscriber_queue_name,
27
+ dead_letter_queue_name: config.subscriber_dead_letter_queue_name
28
+ )
29
+ log.info "Created queue #{queue.url}"
30
+ queue
31
+ end
32
+ end
33
+
34
+ def create_topics
35
+ safe_aws('Create Topics') do
36
+ config.publisher_topic_names.map do |topic_name|
37
+ topic = Circuitry::TopicCreator.find_or_create(topic_name)
38
+ log.info "Created topic #{topic.name}"
39
+ topic
40
+ end
41
+ end
42
+ end
43
+
44
+ def subscribe_topics(queue, topics)
45
+ safe_aws('Subscribe Topics') do
46
+ Circuitry::SubscriptionCreator.subscribe_all(queue, topics)
47
+ log.info "Subscribed all topics to #{queue.name}"
48
+ true
49
+ end
50
+ end
51
+
52
+ def safe_aws(desc)
53
+ yield
54
+ rescue Aws::SQS::Errors::AccessDenied
55
+ log.fatal("#{desc}: Access denied. Check your configured credentials.")
56
+ nil
57
+ end
58
+ end
59
+ end
@@ -12,8 +12,8 @@ module Circuitry
12
12
  include Services::SNS
13
13
 
14
14
  DEFAULT_OPTIONS = {
15
- async: false,
16
- timeout: 15,
15
+ async: false,
16
+ timeout: 15
17
17
  }.freeze
18
18
 
19
19
  attr_reader :timeout
@@ -26,23 +26,14 @@ module Circuitry
26
26
  end
27
27
 
28
28
  def publish(topic_name, object)
29
- raise ArgumentError.new('topic_name cannot be nil') if topic_name.nil?
30
- raise ArgumentError.new('object cannot be nil') if object.nil?
31
- raise PublishError.new('AWS configuration is not set') unless can_publish?
32
-
33
- process = -> do
34
- Timeout.timeout(timeout) do
35
- logger.info("Publishing message to #{topic_name}")
36
-
37
- topic = TopicCreator.find_or_create(topic_name)
38
- sns.publish(topic_arn: topic.arn, message: object.to_json)
39
- end
40
- end
29
+ raise ArgumentError, 'topic_name cannot be nil' if topic_name.nil?
30
+ raise ArgumentError, 'object cannot be nil' if object.nil?
31
+ raise PublishError, 'AWS configuration is not set' unless can_publish?
41
32
 
42
33
  if async?
43
- process_asynchronously(&process)
34
+ process_asynchronously(& -> { publish_internal(topic_name, object) })
44
35
  else
45
- process.call
36
+ publish_internal(topic_name, object)
46
37
  end
47
38
  end
48
39
 
@@ -52,6 +43,17 @@ module Circuitry
52
43
 
53
44
  protected
54
45
 
46
+ def publish_internal(topic_name, object)
47
+ # TODO: Don't use ruby timeout.
48
+ # http://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/
49
+ Timeout.timeout(timeout) do
50
+ logger.info("Publishing message to #{topic_name}")
51
+
52
+ topic = TopicCreator.find_or_create(topic_name)
53
+ sns.publish(topic_arn: topic.arn, message: object.to_json)
54
+ end
55
+ end
56
+
55
57
  attr_writer :timeout
56
58
 
57
59
  private
@@ -0,0 +1,27 @@
1
+ require 'circuitry/services/sqs'
2
+
3
+ module Circuitry
4
+ class Queue
5
+ include Services::SQS
6
+
7
+ attr_reader :url
8
+
9
+ def initialize(url)
10
+ @url = url
11
+ end
12
+
13
+ def name
14
+ url.split('/').last
15
+ end
16
+
17
+ def arn
18
+ @arn ||= attribute('QueueArn')
19
+ end
20
+
21
+ private
22
+
23
+ def attribute(name)
24
+ sqs.get_queue_attributes(queue_url: url, attribute_names: [name]).attributes[name]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ require 'circuitry/services/sqs'
2
+ require 'circuitry/queue'
3
+
4
+ module Circuitry
5
+ class QueueCreator
6
+ include Services::SQS
7
+
8
+ attr_reader :queue_name
9
+
10
+ def self.find_or_create(queue_name, dead_letter_queue_name: nil, max_receive_count: 8 )
11
+ creator = new(queue_name)
12
+ result = creator.create_queue
13
+ creator.create_dead_letter_queue(dead_letter_queue_name, max_receive_count) if dead_letter_queue_name
14
+ result
15
+ end
16
+
17
+ def initialize(queue_name)
18
+ @queue_name = queue_name
19
+ end
20
+
21
+ def create_queue
22
+ @_queue ||= Queue.new(create_primary_queue_internal)
23
+ end
24
+
25
+ def create_dead_letter_queue(name, max_receive_count)
26
+ @_dl_queue ||= Queue.new(create_dl_queue_internal(name, max_receive_count))
27
+ end
28
+
29
+ private
30
+
31
+ def create_dl_queue_internal(name, max_receive_count)
32
+ dl_url = sqs.create_queue(queue_name: name).queue_url
33
+ dl_arn = sqs.get_queue_attributes(queue_url: dl_url, attribute_names: ['QueueArn']).attributes['QueueArn']
34
+
35
+ sqs.set_queue_attributes(queue_url: create_queue.url, attributes: build_redrive_policy(dl_arn, max_receive_count))
36
+ dl_url
37
+ end
38
+
39
+ def build_redrive_policy(deadletter_arn, max_receive_count)
40
+ {
41
+ 'RedrivePolicy' => %({"maxReceiveCount":"#{max_receive_count}", "deadLetterTargetArn":"#{deadletter_arn}"})
42
+ }
43
+ end
44
+
45
+ def create_primary_queue_internal
46
+ attributes = { 'VisibilityTimeout' => (30 * 60).to_s }
47
+ sqs.create_queue(queue_name: queue_name, attributes: attributes).queue_url
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,9 @@
1
+ require 'rails'
2
+
3
+ module Circuitry
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load 'circuitry/tasks.rb'
7
+ end
8
+ end
9
+ end
@@ -14,36 +14,33 @@ module Circuitry
14
14
  attr_reader :queue, :timeout, :wait_time, :batch_size, :lock
15
15
 
16
16
  DEFAULT_OPTIONS = {
17
- lock: true,
18
- async: false,
19
- timeout: 15,
20
- wait_time: 10,
21
- batch_size: 10,
17
+ lock: true,
18
+ async: false,
19
+ timeout: 15,
20
+ wait_time: 10,
21
+ batch_size: 10
22
22
  }.freeze
23
23
 
24
24
  CONNECTION_ERRORS = [
25
- Aws::SQS::Errors::ServiceError,
25
+ Aws::SQS::Errors::ServiceError
26
26
  ].freeze
27
27
 
28
- def initialize(queue, options = {})
29
- raise ArgumentError.new('queue cannot be nil') if queue.nil?
30
-
28
+ def initialize(options = {})
31
29
  options = DEFAULT_OPTIONS.merge(options)
32
30
 
33
31
  self.subscribed = false
34
- self.queue = queue
35
- self.lock = options[:lock]
36
- self.async = options[:async]
37
- self.timeout = options[:timeout]
38
- self.wait_time = options[:wait_time]
39
- self.batch_size = options[:batch_size]
32
+
33
+ self.queue = QueueCreator.find_or_create(Circuitry.config.subscriber_queue_name).url
34
+ %i[lock async timeout wait_time batch_size].each do |sym|
35
+ send(:"#{sym}=", options[sym])
36
+ end
40
37
 
41
38
  trap_signals
42
39
  end
43
40
 
44
41
  def subscribe(&block)
45
- raise ArgumentError.new('block required') if block.nil?
46
- raise SubscribeError.new('AWS configuration is not set') unless can_subscribe?
42
+ raise ArgumentError, 'block required' if block.nil?
43
+ raise SubscribeError, 'AWS configuration is not set' unless can_subscribe?
47
44
 
48
45
  logger.info("Subscribing to queue: #{queue}")
49
46
 
@@ -54,7 +51,7 @@ module Circuitry
54
51
  logger.info("Unsubscribed from queue: #{queue}")
55
52
  rescue *CONNECTION_ERRORS => e
56
53
  logger.error("Connection error to queue: #{queue}: #{e}")
57
- raise SubscribeError.new(e)
54
+ raise SubscribeError, e.message
58
55
  end
59
56
 
60
57
  def subscribed?
@@ -76,17 +73,22 @@ module Circuitry
76
73
 
77
74
  def lock=(value)
78
75
  value = case value
79
- when true then Circuitry.config.lock_strategy
80
- when false then Circuitry::Locks::NOOP.new
81
- when Circuitry::Locks::Base then value
82
- else raise ArgumentError, "Invalid value `#{value}`, must be one of `true`, `false`, or instance of `#{Circuitry::Locks::Base}`"
83
- end
76
+ when true then Circuitry.config.lock_strategy
77
+ when false then Circuitry::Locks::NOOP.new
78
+ when Circuitry::Locks::Base then value
79
+ else raise ArgumentError, lock_value_error(value)
80
+ end
84
81
 
85
82
  @lock = value
86
83
  end
87
84
 
88
85
  private
89
86
 
87
+ def lock_value_error(value)
88
+ opts = Circuitry::Locks::Base
89
+ "Invalid value `#{value}`, must be one of `true`, `false`, or instance of `#{opts}`"
90
+ end
91
+
90
92
  def trap_signals
91
93
  trap('SIGINT') do
92
94
  if subscribed?
@@ -109,19 +111,21 @@ module Circuitry
109
111
  end
110
112
 
111
113
  def process_messages(messages, &block)
112
- messages.each do |message|
113
- process = -> do
114
- process_message(message, &block)
115
- end
116
-
117
- if async?
118
- process_asynchronously(&process)
119
- else
120
- process.call
121
- end
114
+ if async?
115
+ process_messages_asynchronously(messages, &block)
116
+ else
117
+ process_messages_synchronously(messages, &block)
122
118
  end
123
119
  end
124
120
 
121
+ def process_messages_asynchronously(messages, &block)
122
+ messages.each { |message| process_asynchronously(& -> { process_message(message, &block) }) }
123
+ end
124
+
125
+ def process_messages_synchronously(messages, &block)
126
+ messages.each { |message| process_message(message, &block) }
127
+ end
128
+
125
129
  def process_message(message, &block)
126
130
  message = Message.new(message)
127
131
 
@@ -134,20 +138,35 @@ module Circuitry
134
138
  end
135
139
 
136
140
  def handle_message(message, &block)
137
- if lock.soft_lock(message.id)
141
+ handled = try_with_lock(message.id) do
138
142
  begin
143
+ # TODO: Don't use ruby timeout.
144
+ # http://www.mikeperham.com/2015/05/08/timeout-rubys-most-dangerous-api/
139
145
  Timeout.timeout(timeout) do
140
146
  block.call(message.body, message.topic.name)
141
147
  end
142
148
  rescue => e
143
- lock.unlock(message.id)
144
149
  logger.error("Error handling message #{message.id}: #{e}")
145
150
  raise e
146
151
  end
152
+ end
153
+
154
+ logger.info("Ignoring duplicate message #{message.id}") unless handled
155
+ end
156
+
157
+ def try_with_lock(handle)
158
+ if lock.soft_lock(handle)
159
+ begin
160
+ yield
161
+ rescue => e
162
+ lock.unlock(handle)
163
+ raise e
164
+ end
147
165
 
148
- lock.hard_lock(message.id)
166
+ lock.hard_lock(handle)
167
+ true
149
168
  else
150
- logger.info("Ignoring duplicate message #{message.id}")
169
+ false
151
170
  end
152
171
  end
153
172