circuitry 1.4.1 → 2.0.0

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