circuitry 1.4.1 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +7 -0
- data/README.md +88 -62
- data/bin/console +4 -4
- data/circuitry.gemspec +12 -11
- data/exe/circuitry +7 -0
- data/lib/circuitry.rb +5 -3
- data/lib/circuitry/cli.rb +70 -0
- data/lib/circuitry/concerns/async.rb +11 -6
- data/lib/circuitry/configuration.rb +16 -9
- data/lib/circuitry/provisioner.rb +59 -0
- data/lib/circuitry/publisher.rb +18 -16
- data/lib/circuitry/queue.rb +27 -0
- data/lib/circuitry/queue_creator.rb +50 -0
- data/lib/circuitry/railtie.rb +9 -0
- data/lib/circuitry/subscriber.rb +56 -37
- data/lib/circuitry/subscription_creator.rb +60 -0
- data/lib/circuitry/tasks.rb +11 -0
- data/lib/circuitry/topic_creator.rb +2 -2
- data/lib/circuitry/version.rb +1 -1
- metadata +72 -45
@@ -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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
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: ->(
|
14
|
-
attribute :publish_async_strategy, Symbol, default: ->(
|
15
|
-
attribute :subscribe_async_strategy, Symbol, default: ->(
|
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
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
41
|
-
|
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
|
data/lib/circuitry/publisher.rb
CHANGED
@@ -12,8 +12,8 @@ module Circuitry
|
|
12
12
|
include Services::SNS
|
13
13
|
|
14
14
|
DEFAULT_OPTIONS = {
|
15
|
-
|
16
|
-
|
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
|
30
|
-
raise ArgumentError
|
31
|
-
raise PublishError
|
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(&
|
34
|
+
process_asynchronously(& -> { publish_internal(topic_name, object) })
|
44
35
|
else
|
45
|
-
|
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
|
data/lib/circuitry/subscriber.rb
CHANGED
@@ -14,36 +14,33 @@ module Circuitry
|
|
14
14
|
attr_reader :queue, :timeout, :wait_time, :batch_size, :lock
|
15
15
|
|
16
16
|
DEFAULT_OPTIONS = {
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
25
|
+
Aws::SQS::Errors::ServiceError
|
26
26
|
].freeze
|
27
27
|
|
28
|
-
def initialize(
|
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
|
-
|
35
|
-
self.
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
46
|
-
raise SubscribeError
|
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.
|
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
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
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
|
-
|
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(
|
166
|
+
lock.hard_lock(handle)
|
167
|
+
true
|
149
168
|
else
|
150
|
-
|
169
|
+
false
|
151
170
|
end
|
152
171
|
end
|
153
172
|
|